diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 000000000..e7f705722
--- /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/app/build.gradle b/app/build.gradle
index bfb73f9fc..ed3250f0c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -17,12 +17,13 @@ buildscript {
plugins {
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
+ id 'com.google.devtools.ksp'
+ id 'app.cash.molecule'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'witness'
-apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlinx-serialization'
apply plugin: 'dagger.hilt.android.plugin'
@@ -47,12 +48,12 @@ android {
useLibrary 'org.apache.http.legacy'
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
- jvmTarget = '1.8'
+ jvmTarget = '11'
}
packagingOptions {
@@ -78,7 +79,7 @@ android {
compose true
}
composeOptions {
- kotlinCompilerExtensionVersion '1.4.7'
+ kotlinCompilerExtensionVersion '1.5.3'
}
defaultConfig {
@@ -205,8 +206,14 @@ android {
dependencies {
- implementation("com.google.dagger:hilt-android:2.46.1")
- kapt("com.google.dagger:hilt-android-compiler:2.44")
+ implementation("com.google.dagger:hilt-android:$daggerVersion")
+ kapt("com.google.dagger:hilt-android-compiler:$daggerVersion")
+ implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
+
+ implementation "io.github.raamcosta.compose-destinations:core:$composeDestinationsVersion"
+ ksp "io.github.raamcosta.compose-destinations:ksp:$composeDestinationsVersion"
+
+
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.recyclerview:recyclerview:1.2.1'
@@ -299,9 +306,9 @@ dependencies {
implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'
- testImplementation "org.mockito:mockito-inline:4.10.0"
+ testImplementation "org.mockito:mockito-inline:4.11.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
- androidTestImplementation "org.mockito:mockito-android:4.10.0"
+ androidTestImplementation "org.mockito:mockito-android:4.11.0"
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.2.0"
@@ -313,7 +320,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'
@@ -331,20 +337,22 @@ 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.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
- implementation 'androidx.compose.ui:ui:1.5.2'
- implementation 'androidx.compose.ui:ui-tooling:1.5.2'
+ implementation 'androidx.compose.ui:ui:1.5.3'
+ implementation 'androidx.compose.ui:ui-tooling:1.5.3'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
- implementation "androidx.compose.runtime:runtime-livedata:1.5.2"
+ implementation "androidx.compose.runtime:runtime-livedata:1.5.3"
- implementation 'androidx.compose.foundation:foundation-layout:1.5.2'
- implementation 'androidx.compose.material:material:1.5.2'
+ implementation 'androidx.compose.foundation:foundation-layout:1.5.3'
+ implementation 'androidx.compose.material:material:1.5.3'
}
static def getLastCommitTimestamp() {
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
index deab87dd6..be464ad75 100644
--- a/app/src/androidTest/AndroidManifest.xml
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -1,8 +1,10 @@
+ xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+
\ 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 000000000..a03a225a1
--- /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.compose.ViewState
+import org.thoughtcrime.securesms.ui.AppTheme
+
+@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 {
+ AppTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { backPressed = true },
+ onClose = { closePressed = true },
+ onSelectContact = {},
+ 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 {
+ AppTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { backPressed = true },
+ onClose = { closePressed = true },
+ updateState = {},
+ onSelectContact = {}
+ )
+ }
+ }
+ 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 {
+ AppTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { backPressed = true },
+ onClose = {},
+ onSelectContact = {},
+ 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 {
+ AppTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { },
+ onClose = { closePressed = true },
+ onSelectContact = {},
+ 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/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
index a20a3a2a6..822564b4f 100644
--- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
+++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
@@ -1,14 +1,10 @@
package network.loki.messenger
-import android.Manifest
import android.app.Instrumentation
import android.content.ClipboardManager
import android.content.Context
-import android.view.View
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
@@ -20,11 +16,11 @@ import androidx.test.espresso.matcher.ViewMatchers.withSubstring
import androidx.test.espresso.matcher.ViewMatchers.withText
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 com.adevinta.android.barista.interaction.PermissionGranter
-import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
-import org.hamcrest.Matcher
+import network.loki.messenger.util.sendMessage
+import network.loki.messenger.util.setupLoggedInState
+import network.loki.messenger.util.waitFor
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.not
import org.junit.After
@@ -36,12 +32,10 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
-import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
import org.thoughtcrime.securesms.home.HomeActivity
-import org.thoughtcrime.securesms.mms.GlideApp
@RunWith(AndroidJUnit4::class)
-@LargeTest
+@SmallTest
class HomeActivityTests {
@get:Rule
@@ -59,38 +53,6 @@ class HomeActivityTests {
InstrumentationRegistry.getInstrumentation().removeMonitor(activityMonitor)
}
- private fun sendMessage(messageToSend: String, linkPreview: LinkPreview? = null) {
- // assume in chat activity
- onView(allOf(isDescendantOfA(withId(R.id.inputBar)),withId(R.id.inputBarEditText))).perform(ViewActions.replaceText(messageToSend))
- if (linkPreview != null) {
- val activity = activityMonitor.waitForActivity() as ConversationActivityV2
- val glide = GlideApp.with(activity)
- activity.findViewById(R.id.inputBar).updateLinkPreviewDraft(glide, linkPreview)
- }
- onView(allOf(isDescendantOfA(withId(R.id.inputBar)),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(isRoot()).perform(waitFor(500))
- }
-
- private fun setupLoggedInState(hasViewedSeed: Boolean = false) {
- // landing activity
- onView(withId(R.id.registerButton)).perform(ViewActions.click())
- // session ID - register activity
- onView(withId(R.id.registerButton)).perform(ViewActions.click())
- // display name selection
- onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123"))
- onView(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(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
- onView(withId(R.id.registerButton)).perform(ViewActions.click())
- // allow notification permission
- PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
- }
-
private fun goToMyChat() {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
@@ -134,11 +96,13 @@ class HomeActivityTests {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, 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
@@ -148,7 +112,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())
@@ -162,21 +128,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 59cb8ede0..e58f1db5f 100644
--- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt
+++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt
@@ -9,12 +9,15 @@ import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode
+import 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.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq
-import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.TextSecurePreferences
@@ -22,32 +25,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 randomSessionId() = 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.Companion.newInstance(key)
@@ -80,9 +66,8 @@ 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 = randomSessionId()
val singleContact = Contact(
@@ -93,10 +78,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
})
- verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
+ verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
}
\ No newline at end of file
diff --git a/app/src/androidTest/java/network/loki/messenger/groups/ClosedGroupViewTests.kt b/app/src/androidTest/java/network/loki/messenger/groups/ClosedGroupViewTests.kt
new file mode 100644
index 000000000..36f8e5cd2
--- /dev/null
+++ b/app/src/androidTest/java/network/loki/messenger/groups/ClosedGroupViewTests.kt
@@ -0,0 +1,77 @@
+package network.loki.messenger.groups
+
+import android.content.Context
+import androidx.test.platform.app.InstrumentationRegistry
+import junit.framework.TestCase.assertNotNull
+import network.loki.messenger.libsession_util.util.Sodium
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsignal.utilities.Hex
+import org.session.libsignal.utilities.IdPrefix
+import org.session.libsignal.utilities.SessionId
+import org.thoughtcrime.securesms.database.ConfigDatabase
+import org.thoughtcrime.securesms.database.Storage
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
+import org.thoughtcrime.securesms.groups.CreateGroupViewModel
+
+@RunWith(MockitoJUnitRunner::class)
+class ClosedGroupViewTests {
+
+ companion object {
+ private const val OTHER_ID = "051000000000000000000000000000000000000000000000000000000000000000"
+ }
+
+ private val seed =
+ Hex.fromStringCondensed("0123456789abcdef0123456789abcdef00000000000000000000000000000000")
+ private val keyPair = Sodium.ed25519KeyPair(seed)
+ private val userSessionId = SessionId(IdPrefix.STANDARD, Sodium.ed25519PkToCurve25519(keyPair.pubKey))
+
+ @Mock lateinit var textSecurePreferences: TextSecurePreferences
+ lateinit var storage: Storage
+
+ @Before
+ fun setup() {
+ val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext
+ whenever(textSecurePreferences.getLocalNumber()).thenReturn(userSessionId.hexString())
+ val context = mock()
+ val emptyDb = mock { db ->
+ whenever(db.retrieveConfigAndHashes(any(), any())).thenReturn(byteArrayOf())
+ }
+ val overriddenStorage = Storage(applicationContext, mock(), ConfigFactory(context, emptyDb) {
+ keyPair.secretKey to userSessionId.hexString()
+ }, mock(), { stringRes, toastLength, parameters ->
+
+ })
+ storage = overriddenStorage
+ }
+
+ @Test
+ fun tryCreateGroup_shouldErrorOnEmptyName() {
+ val viewModel = createViewModel()
+ viewModel.tryCreateGroup()
+ assertNotNull(viewModel.viewState.value?.error)
+ }
+
+ @Test
+ fun tryCreateGroup_shouldErrorOnEmptyMembers() {
+ val viewModel = createViewModel()
+ viewModel.tryCreateGroup()
+ assertNotNull(viewModel.viewState.value?.error)
+ }
+
+ @Test
+ fun tryCreateGroup_shouldSucceedWithCorrectParameters() {
+ val viewModel = createViewModel()
+ assertNotNull(viewModel.tryCreateGroup())
+ }
+
+ private fun createViewModel() = CreateGroupViewModel(storage)
+
+}
\ No newline at end of file
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 000000000..e7a3ce107
--- /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 000000000..7b02efad6
--- /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 f2a4642b7..d0a6e63bc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -152,8 +152,13 @@
android:theme="@style/Theme.Session.DayNight.FlatActionBar"
android:label="@string/blocked_contacts_title"
/>
+
+
+
KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory
- );
+ );
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
startKovenant();
@@ -282,13 +283,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) {
poller.stopIfNeeded();
}
- ClosedGroupPollerV2.getShared().stopAll();
+ pollerFactory.stopAll();
+ LegacyClosedGroupPollerV2.getShared().stopAll();
}
@Override
public void onTerminate() {
stopKovenant(); // Loki
OpenGroupManager.INSTANCE.stopPolling();
+ pollerFactory.stopAll();
super.onTerminate();
}
@@ -437,7 +440,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() {
@@ -445,7 +448,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) {
poller.startIfNeeded();
}
- ClosedGroupPollerV2.getShared().start();
+ pollerFactory.startAll();
+ LegacyClosedGroupPollerV2.getShared().start();
}
private void resubmitProfilePictureIfNeeded() {
@@ -500,9 +504,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
public void clearAllData(boolean isMigratingToV2KeyPair) {
- if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
- firebaseInstanceIdJob.cancel(null);
- }
String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(this);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java
deleted file mode 100644
index 0fd813cf4..000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Copyright (C) 2015 Open Whisper Systems
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.thoughtcrime.securesms;
-
-import android.content.Context;
-import androidx.annotation.NonNull;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter;
-
-
-import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
-import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
-import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
-import org.thoughtcrime.securesms.mms.GlideRequests;
-import org.thoughtcrime.securesms.mms.Slide;
-import org.thoughtcrime.securesms.util.MediaUtil;
-
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Locale;
-import java.util.Set;
-
-import network.loki.messenger.R;
-
-class MediaGalleryAdapter extends StickyHeaderGridAdapter {
-
- @SuppressWarnings("unused")
- private static final String TAG = MediaGalleryAdapter.class.getSimpleName();
-
- private final Context context;
- private final GlideRequests glideRequests;
- private final Locale locale;
- private final ItemClickListener itemClickListener;
- private final Set selected;
-
- private BucketedThreadMedia media;
-
- private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder {
- ThumbnailView imageView;
- View selectedIndicator;
-
- ViewHolder(View v) {
- super(v);
- imageView = v.findViewById(R.id.image);
- selectedIndicator = v.findViewById(R.id.selected_indicator);
- }
- }
-
- private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder {
- TextView textView;
-
- HeaderHolder(View itemView) {
- super(itemView);
- textView = itemView.findViewById(R.id.text);
- }
- }
-
- MediaGalleryAdapter(@NonNull Context context,
- @NonNull GlideRequests glideRequests,
- BucketedThreadMedia media,
- Locale locale,
- ItemClickListener clickListener)
- {
- this.context = context;
- this.glideRequests = glideRequests;
- this.locale = locale;
- this.media = media;
- this.itemClickListener = clickListener;
- this.selected = new HashSet<>();
- }
-
- public void setMedia(BucketedThreadMedia media) {
- this.media = media;
- }
-
- @Override
- public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) {
- return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item_header, parent, false));
- }
-
- @Override
- public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) {
- return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item, parent, false));
- }
-
- @Override
- public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) {
- ((HeaderHolder)viewHolder).textView.setText(media.getName(section, locale));
- }
-
- @Override
- public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) {
- MediaRecord mediaRecord = media.get(section, offset);
- ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView;
- View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator;
- Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
-
- if (slide != null) {
- thumbnailView.setImageResource(glideRequests, slide, false, null);
- }
-
- thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
- thumbnailView.setOnLongClickListener(view -> {
- itemClickListener.onMediaLongClicked(mediaRecord);
- return true;
- });
-
- selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE);
- }
-
- @Override
- public int getSectionCount() {
- return media.getSectionCount();
- }
-
- @Override
- public int getSectionItemCount(int section) {
- return media.getSectionItemCount(section);
- }
-
- public void toggleSelection(@NonNull MediaRecord mediaRecord) {
- if (!selected.remove(mediaRecord)) {
- selected.add(mediaRecord);
- }
- notifyDataSetChanged();
- }
-
- public int getSelectedMediaCount() {
- return selected.size();
- }
-
- @NonNull
- public Collection getSelectedMedia() {
- return new HashSet<>(selected);
- }
-
- public void clearSelection() {
- selected.clear();
- notifyDataSetChanged();
- }
-
- void selectAllMedia() {
- for (int section = 0; section < media.getSectionCount(); section++) {
- for (int item = 0; item < media.getSectionItemCount(section); item++) {
- selected.add(media.get(section, item));
- }
- }
- this.notifyDataSetChanged();
- }
-
- interface ItemClickListener {
- void onMediaClicked(@NonNull MediaRecord mediaRecord);
- void onMediaLongClicked(MediaRecord mediaRecord);
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.kt
new file mode 100644
index 000000000..d36cc4828
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.kt
@@ -0,0 +1,87 @@
+package org.thoughtcrime.securesms
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import network.loki.messenger.R
+import network.loki.messenger.databinding.MediaOverviewGalleryItemBinding
+import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord
+import org.thoughtcrime.securesms.mms.GlideApp
+import org.thoughtcrime.securesms.util.MediaUtil
+
+class MediaGalleryAdapter(private val itemClickListener: ItemClickListener): RecyclerView.Adapter() {
+
+ private val items: MutableList = mutableListOf()
+ private val selectedItems: MutableSet = mutableSetOf()
+
+ fun setItems(newItems: List) {
+ items.clear()
+ items += newItems
+ notifyDataSetChanged()
+ }
+
+ fun toggleSelection(record: MediaRecord) {
+ val index = items.indexOf(record)
+ if (index >= 0) {
+ if (selectedItems.contains(record)) selectedItems -= record
+ else selectedItems += record
+ notifyItemChanged(index)
+ }
+ }
+
+ fun getSelectedMediaCount() = selectedItems.size
+
+ fun selectAllMedia() {
+ selectedItems += items
+ val size = items.size
+ notifyItemRangeChanged(0, size)
+ }
+
+ fun getSelectedMedia() = selectedItems.toList()
+
+ fun clearSelection() {
+ selectedItems.clear()
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context).inflate(R.layout.media_overview_gallery_item, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = items[position]
+ holder.bind(item, selectedItems.contains(item), itemClickListener)
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
+
+ private val binding = MediaOverviewGalleryItemBinding.bind(itemView)
+ private val glide = GlideApp.with(itemView)
+
+ fun bind(item: MediaRecord, isSelected: Boolean, itemClickListener: ItemClickListener) {
+ val slide = MediaUtil.getSlideForAttachment(itemView.context, item.attachment)
+
+ if (slide != null) {
+ binding.image.root.setImageResource(glide, slide, false, null)
+ }
+
+ binding.image.root.setOnClickListener { itemClickListener.onMediaClicked(item) }
+ binding.image.root.setOnLongClickListener {
+ itemClickListener.onMediaLongClicked(item)
+ true
+ }
+ binding.selectedIndicator.isVisible = isSelected
+ }
+ }
+
+ interface ItemClickListener {
+ fun onMediaClicked(mediaRecord: MediaRecord)
+ fun onMediaLongClicked(mediaRecord: MediaRecord?)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java
index 95ba15c82..ae5824e86 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java
@@ -20,7 +20,6 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
-import android.content.res.Resources;
import android.database.Cursor;
import android.os.Build;
import android.os.Bundle;
@@ -35,7 +34,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
@@ -45,32 +43,32 @@ import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DividerItemDecoration;
+import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
-import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
import com.google.android.material.tabs.TabLayout;
+import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
-import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
-import org.thoughtcrime.securesms.database.MediaDatabase;
-import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
-import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
-import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
-import org.thoughtcrime.securesms.mms.GlideApp;
-import org.thoughtcrime.securesms.permissions.Permissions;
-import org.session.libsession.utilities.recipients.Recipient;
-import org.thoughtcrime.securesms.util.AttachmentUtil;
-import org.thoughtcrime.securesms.util.SaveAttachmentTask;
-import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
+import org.session.libsession.utilities.GroupRecord;
+import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.ViewUtil;
+import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
+import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
+import org.thoughtcrime.securesms.database.MediaDatabase;
+import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.util.AttachmentUtil;
+import org.thoughtcrime.securesms.util.SaveAttachmentTask;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
@@ -82,423 +80,450 @@ import network.loki.messenger.R;
/**
* Activity for displaying media attachments in-app
*/
-public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
+public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements View.OnClickListener {
- @SuppressWarnings("unused")
- private final static String TAG = MediaOverviewActivity.class.getSimpleName();
-
- public static final String ADDRESS_EXTRA = "address";
-
- private Toolbar toolbar;
- private TabLayout tabLayout;
- private ViewPager viewPager;
- private Recipient recipient;
-
- @Override
- protected void onCreate(Bundle bundle, boolean ready) {
- setContentView(R.layout.media_overview_activity);
-
- initializeResources();
- initializeToolbar();
-
- this.tabLayout.setupWithViewPager(viewPager);
- this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- super.onOptionsItemSelected(item);
-
- switch (item.getItemId()) {
- case android.R.id.home: finish(); return true;
- }
-
- return false;
- }
-
- private void initializeResources() {
- Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
-
- this.viewPager = ViewUtil.findById(this, R.id.pager);
- this.toolbar = ViewUtil.findById(this, R.id.toolbar);
- this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
- this.recipient = Recipient.from(this, address, true);
- }
-
- private void initializeToolbar() {
- setSupportActionBar(this.toolbar);
- ActionBar actionBar = getSupportActionBar();
- actionBar.setTitle(recipient.toShortString());
- actionBar.setDisplayHomeAsUpEnabled(true);
- actionBar.setHomeButtonEnabled(true);
- this.recipient.addListener(recipient -> {
- Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
- });
- }
-
- public void onEnterMultiSelect() {
- tabLayout.setEnabled(false);
- viewPager.setEnabled(false);
- }
-
- public void onExitMultiSelect() {
- tabLayout.setEnabled(true);
- viewPager.setEnabled(true);
- }
-
- private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
-
- MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
- super(fragmentManager);
- }
-
- @Override
- public Fragment getItem(int position) {
- Fragment fragment;
-
- if (position == 0) fragment = new MediaOverviewGalleryFragment();
- else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
- else throw new AssertionError();
-
- Bundle args = new Bundle();
- args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
- args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
-
- fragment.setArguments(args);
-
- return fragment;
- }
-
- @Override
- public int getCount() {
- return 2;
- }
-
- @Override
- public CharSequence getPageTitle(int position) {
- if (position == 0) return getString(R.string.MediaOverviewActivity_Media);
- else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
- else throw new AssertionError();
- }
- }
-
- public static abstract class MediaOverviewFragment extends Fragment implements LoaderManager.LoaderCallbacks {
+ @SuppressWarnings("unused")
+ private final static String TAG = MediaOverviewActivity.class.getSimpleName();
public static final String ADDRESS_EXTRA = "address";
- public static final String LOCALE_EXTRA = "locale_extra";
- protected TextView noMedia;
- protected Recipient recipient;
- protected RecyclerView recyclerView;
- protected Locale locale;
+ private Toolbar toolbar;
+ private TabLayout tabLayout;
+ private ViewPager viewPager;
+ private Recipient recipient;
@Override
- public void onCreate(Bundle bundle) {
- super.onCreate(bundle);
+ protected void onCreate(Bundle bundle, boolean ready) {
+ setContentView(R.layout.media_overview_activity);
- String address = getArguments().getString(ADDRESS_EXTRA);
- Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA);
+ initializeResources();
+ initializeToolbar();
- if (address == null) throw new AssertionError();
- if (locale == null) throw new AssertionError();
-
- this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
- this.locale = locale;
-
- getLoaderManager().initLoader(0, null, this);
- }
- }
-
- public static class MediaOverviewGalleryFragment
- extends MediaOverviewFragment
- implements MediaGalleryAdapter.ItemClickListener
- {
-
- private StickyHeaderGridLayoutManager gridManager;
- private ActionMode actionMode;
- private ActionModeCallback actionModeCallback = new ActionModeCallback();
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
-
- this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
- this.noMedia = ViewUtil.findById(view, R.id.no_images);
- this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
-
- this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(),
- GlideApp.with(this),
- new BucketedThreadMedia(getContext()),
- locale,
- this));
- this.recyclerView.setLayoutManager(gridManager);
- this.recyclerView.setHasFixedSize(true);
-
- return view;
+ this.tabLayout.setupWithViewPager(viewPager);
+ this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
}
@Override
- public void onConfigurationChanged(Configuration newConfig) {
- super.onConfigurationChanged(newConfig);
- if (gridManager != null) {
- this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
- this.recyclerView.setLayoutManager(gridManager);
- }
+ public boolean onOptionsItemSelected(MenuItem item) {
+ super.onOptionsItemSelected(item);
+
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ }
+
+ return false;
+ }
+
+ private void initializeResources() {
+ Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
+
+ this.viewPager = ViewUtil.findById(this, R.id.pager);
+ this.toolbar = ViewUtil.findById(this, R.id.toolbar);
+ this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
+ this.recipient = Recipient.from(this, address, true);
+ }
+
+ private void initializeToolbar() {
+ setSupportActionBar(this.toolbar);
+ ActionBar actionBar = getSupportActionBar();
+ actionBar.setTitle(recipient.toShortString());
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setHomeButtonEnabled(true);
+ this.recipient.addListener(recipient -> {
+ Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
+ });
+ View clearButton = toolbar.findViewById(R.id.clearMedia);
+ if (!this.recipient.isClosedGroupRecipient()) {
+ clearButton.setVisibility(View.GONE);
+ } else {
+ String userPublicKey = TextSecurePreferences.getLocalNumber(this);
+ GroupRecord groupRecord = MessagingModuleConfiguration.getShared().getStorage().getGroup(this.recipient.getAddress().toGroupString());
+ if (userPublicKey == null || groupRecord == null) {
+ clearButton.setVisibility(View.GONE);
+ } else {
+ boolean isUserAdmin = groupRecord.getAdmins().contains(Address.fromSerialized(userPublicKey));
+ clearButton.setVisibility(isUserAdmin ? View.VISIBLE : View.GONE);
+ clearButton.setOnClickListener(this);
+ }
+ }
+ }
+
+ public void onEnterMultiSelect() {
+ tabLayout.setEnabled(false);
+ viewPager.setEnabled(false);
}
@Override
- public @NonNull Loader onCreateLoader(int i, Bundle bundle) {
- return new BucketedThreadMediaLoader(getContext(), recipient.getAddress());
+ public void onClick(View v) {
+ if (v.getId() == R.id.clearMedia) {
+ // TODO: future chunk
+ }
}
- @Override
- public void onLoadFinished(@NonNull Loader loader, BucketedThreadMedia bucketedThreadMedia) {
- ((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia);
- ((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
-
- noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
- getActivity().invalidateOptionsMenu();
+ public void onExitMultiSelect() {
+ tabLayout.setEnabled(true);
+ viewPager.setEnabled(true);
}
- @Override
- public void onLoaderReset(@NonNull Loader cursorLoader) {
- ((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext()));
- }
+ private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
- @Override
- public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
- if (actionMode != null) {
- handleMediaMultiSelectClick(mediaRecord);
- } else {
- handleMediaPreviewClick(mediaRecord);
- }
- }
+ MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
+ super(fragmentManager);
+ }
- private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
- MediaGalleryAdapter adapter = getListAdapter();
-
- adapter.toggleSelection(mediaRecord);
- if (adapter.getSelectedMediaCount() == 0) {
- actionMode.finish();
- } else {
- actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
- }
- }
-
- private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
- if (mediaRecord.getAttachment().getDataUri() == null) {
- return;
- }
-
- Context context = getContext();
- if (context == null) {
- return;
- }
-
- Intent intent = new Intent(context, MediaPreviewActivity.class);
- intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
- intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
- intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
- intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
- intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
-
- intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
- context.startActivity(intent);
- }
-
- @Override
- public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
- if (actionMode == null) {
- ((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
- recyclerView.getAdapter().notifyDataSetChanged();
-
- enterMultiSelect();
- }
- }
-
- @SuppressWarnings("CodeBlock2Expr")
- @SuppressLint({"InlinedApi", "StaticFieldLeak"})
- private void handleSaveMedia(@NonNull Collection mediaRecords) {
- final Context context = requireContext();
-
- SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
- Permissions.with(this)
- .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
- .maxSdkVersion(Build.VERSION_CODES.P)
- .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
- .onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
- .onAllGranted(() -> {
- new ProgressDialogAsyncTask>(
- context,
- R.string.MediaOverviewActivity_collecting_attachments,
- R.string.please_wait) {
- @Override
- protected List doInBackground(Void... params) {
- List attachments = new LinkedList<>();
-
- for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
- if (mediaRecord.getAttachment().getDataUri() != null) {
- attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
- mediaRecord.getContentType(),
- mediaRecord.getDate(),
- mediaRecord.getAttachment().getFileName()));
- }
- }
-
- return attachments;
- }
-
- @Override
- protected void onPostExecute(List attachments) {
- super.onPostExecute(attachments);
- SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
- saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
- attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
- actionMode.finish();
- boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
- if (containsIncoming) {
- sendMediaSavedNotificationIfNeeded();
- }
- }
- }.execute();
- })
- .execute();
- return Unit.INSTANCE;
- });
- }
-
- private void sendMediaSavedNotificationIfNeeded() {
- if (recipient.isGroupRecipient()) return;
- DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
- MessageSender.send(message, recipient.getAddress());
- }
-
- @SuppressLint("StaticFieldLeak")
- private void handleDeleteMedia(@NonNull Collection mediaRecords) {
- int recordCount = mediaRecords.size();
-
- DeleteMediaDialog.show(
- requireContext(),
- recordCount,
- () -> new ProgressDialogAsyncTask(
- requireContext(),
- R.string.MediaOverviewActivity_Media_delete_progress_title,
- R.string.MediaOverviewActivity_Media_delete_progress_message) {
@Override
- protected Void doInBackground(MediaDatabase.MediaRecord... records) {
- if (records == null || records.length == 0) {
- return null;
- }
+ public Fragment getItem(int position) {
+ Fragment fragment;
- for (MediaDatabase.MediaRecord record : records) {
- AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
- }
- return null;
+ if (position == 0) fragment = new MediaOverviewGalleryFragment();
+ else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
+ else throw new AssertionError();
+
+ Bundle args = new Bundle();
+ args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
+ args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
+
+ fragment.setArguments(args);
+
+ return fragment;
}
- }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
- }
- private void handleSelectAllMedia() {
- getListAdapter().selectAllMedia();
- actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
- }
-
- private MediaGalleryAdapter getListAdapter() {
- return (MediaGalleryAdapter) recyclerView.getAdapter();
- }
-
- private void enterMultiSelect() {
- actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
- ((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
- }
-
- private class ActionModeCallback implements ActionMode.Callback {
-
- private int originalStatusBarColor;
-
- @Override
- public boolean onCreateActionMode(ActionMode mode, Menu menu) {
- mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
- mode.setTitle("1");
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- Window window = getActivity().getWindow();
- originalStatusBarColor = window.getStatusBarColor();
- window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar));
+ @Override
+ public int getCount() {
+ return 2;
}
- return true;
- }
- @Override
- public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
- return false;
- }
-
- @Override
- public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
- switch (menuItem.getItemId()) {
- case R.id.save:
- handleSaveMedia(getListAdapter().getSelectedMedia());
- return true;
- case R.id.delete:
- handleDeleteMedia(getListAdapter().getSelectedMedia());
- actionMode.finish();
- return true;
- case R.id.select_all:
- handleSelectAllMedia();
- return true;
+ @Override
+ public CharSequence getPageTitle(int position) {
+ if (position == 0) return getString(R.string.MediaOverviewActivity_Media);
+ else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
+ else throw new AssertionError();
}
- return false;
- }
+ }
- @Override
- public void onDestroyActionMode(ActionMode mode) {
- actionMode = null;
- getListAdapter().clearSelection();
- ((MediaOverviewActivity) getActivity()).onExitMultiSelect();
+ public static abstract class MediaOverviewFragment extends Fragment implements LoaderManager.LoaderCallbacks {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- getActivity().getWindow().setStatusBarColor(originalStatusBarColor);
+ public static final String ADDRESS_EXTRA = "address";
+ public static final String LOCALE_EXTRA = "locale_extra";
+
+ protected TextView noMedia;
+ protected Recipient recipient;
+ protected RecyclerView recyclerView;
+ protected Locale locale;
+
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+
+ String address = getArguments().getString(ADDRESS_EXTRA);
+ Locale locale = (Locale) getArguments().getSerializable(LOCALE_EXTRA);
+
+ if (address == null) throw new AssertionError();
+ if (locale == null) throw new AssertionError();
+
+ this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
+ this.locale = locale;
+
+ getLoaderManager().initLoader(0, null, this);
}
- }
- }
- }
-
- public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment {
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
- MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
-
- this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
- this.noMedia = ViewUtil.findById(view, R.id.no_documents);
-
- this.recyclerView.setAdapter(adapter);
- this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
- this.recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, false, true));
- this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
-
- return view;
}
- @Override
- public @NonNull Loader onCreateLoader(int id, Bundle args) {
- return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
+ public static class MediaOverviewGalleryFragment
+ extends MediaOverviewFragment
+ implements MediaGalleryAdapter.ItemClickListener {
+
+ private GridLayoutManager gridManager;
+ private ActionMode actionMode;
+ private ActionModeCallback actionModeCallback = new ActionModeCallback();
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
+
+ this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
+ this.noMedia = ViewUtil.findById(view, R.id.no_images);
+ this.gridManager = new GridLayoutManager(getContext(), getResources().getInteger(R.integer.media_overview_cols));
+
+ this.recyclerView.setAdapter(new MediaGalleryAdapter(this));
+ this.recyclerView.setLayoutManager(gridManager);
+ this.recyclerView.setHasFixedSize(true);
+
+ return view;
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ if (gridManager != null) {
+ this.gridManager = new GridLayoutManager(getContext(), getResources().getInteger(R.integer.media_overview_cols));
+ this.recyclerView.setLayoutManager(gridManager);
+ }
+ }
+
+ @Override
+ public @NonNull
+ Loader onCreateLoader(int i, Bundle bundle) {
+ return new ThreadMediaLoader(requireContext(), recipient.getAddress(), true);
+ }
+
+ @Override
+ public void onLoadFinished(@NonNull Loader loader, Cursor cursor) {
+ List mediaRecords = new ArrayList<>();
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ mediaRecords.add(MediaDatabase.MediaRecord.from(requireContext(), cursor));
+ } while (cursor.moveToNext());
+ }
+
+ MediaGalleryAdapter adapter = getListAdapter();
+ adapter.setItems(mediaRecords);
+
+ noMedia.setVisibility(adapter.getItemCount() > 0 ? View.GONE : View.VISIBLE);
+ requireActivity().invalidateOptionsMenu();
+ }
+
+ @Override
+ public void onLoaderReset(@NonNull Loader cursorLoader) {
+ getListAdapter().setItems(new ArrayList<>());
+ }
+
+ @Override
+ public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
+ if (actionMode != null) {
+ handleMediaMultiSelectClick(mediaRecord);
+ } else {
+ handleMediaPreviewClick(mediaRecord);
+ }
+ }
+
+ private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
+ MediaGalleryAdapter adapter = getListAdapter();
+
+ adapter.toggleSelection(mediaRecord);
+ if (adapter.getSelectedMediaCount() == 0) {
+ actionMode.finish();
+ } else {
+ actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
+ }
+ }
+
+ private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
+ if (mediaRecord.getAttachment().getDataUri() == null) {
+ return;
+ }
+
+ Context context = getContext();
+ if (context == null) {
+ return;
+ }
+
+ Intent intent = new Intent(context, MediaPreviewActivity.class);
+ intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
+ intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
+ intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
+ intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
+ intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
+
+ intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
+ if (actionMode == null) {
+ ((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
+ recyclerView.getAdapter().notifyDataSetChanged();
+
+ enterMultiSelect();
+ }
+ }
+
+ @SuppressWarnings("CodeBlock2Expr")
+ @SuppressLint({"InlinedApi", "StaticFieldLeak"})
+ private void handleSaveMedia(@NonNull Collection mediaRecords) {
+ final Context context = requireContext();
+
+ SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
+ Permissions.with(this)
+ .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .maxSdkVersion(Build.VERSION_CODES.P)
+ .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
+ .onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
+ .onAllGranted(() -> {
+ new ProgressDialogAsyncTask>(
+ context,
+ R.string.MediaOverviewActivity_collecting_attachments,
+ R.string.please_wait) {
+ @Override
+ protected List doInBackground(Void... params) {
+ List attachments = new LinkedList<>();
+
+ for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
+ if (mediaRecord.getAttachment().getDataUri() != null) {
+ attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
+ mediaRecord.getContentType(),
+ mediaRecord.getDate(),
+ mediaRecord.getAttachment().getFileName()));
+ }
+ }
+
+ return attachments;
+ }
+
+ @Override
+ protected void onPostExecute(List attachments) {
+ super.onPostExecute(attachments);
+ SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
+ saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
+ attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
+ actionMode.finish();
+ boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
+ if (containsIncoming) {
+ sendMediaSavedNotificationIfNeeded();
+ }
+ }
+ }.execute();
+ })
+ .execute();
+ return Unit.INSTANCE;
+ });
+ }
+
+ private void sendMediaSavedNotificationIfNeeded() {
+ if (recipient.isGroupRecipient()) return;
+ DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
+ MessageSender.send(message, recipient.getAddress());
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private void handleDeleteMedia(@NonNull Collection mediaRecords) {
+ int recordCount = mediaRecords.size();
+
+ DeleteMediaDialog.show(
+ requireContext(),
+ recordCount,
+ () ->
+ new ProgressDialogAsyncTask(requireContext(),
+ R.string.MediaOverviewActivity_Media_delete_progress_title,
+ R.string.MediaOverviewActivity_Media_delete_progress_message) {
+ @Override
+ protected Void doInBackground(MediaDatabase.MediaRecord... records) {
+ if (records == null || records.length == 0) {
+ return null;
+ }
+
+ for (MediaDatabase.MediaRecord record : records) {
+ AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
+ }
+ return null;
+ }
+
+ }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
+ }
+
+ private void handleSelectAllMedia() {
+ getListAdapter().selectAllMedia();
+ actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
+ }
+
+ private MediaGalleryAdapter getListAdapter() {
+ return (MediaGalleryAdapter) recyclerView.getAdapter();
+ }
+
+ private void enterMultiSelect() {
+ actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
+ ((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
+ }
+
+ private class ActionModeCallback implements ActionMode.Callback {
+
+ private int originalStatusBarColor;
+
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
+ mode.setTitle("1");
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ Window window = getActivity().getWindow();
+ originalStatusBarColor = window.getStatusBarColor();
+ window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar));
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
+ switch (menuItem.getItemId()) {
+ case R.id.save:
+ handleSaveMedia(getListAdapter().getSelectedMedia());
+ return true;
+ case R.id.delete:
+ handleDeleteMedia(getListAdapter().getSelectedMedia());
+ actionMode.finish();
+ return true;
+ case R.id.select_all:
+ handleSelectAllMedia();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ actionMode = null;
+ getListAdapter().clearSelection();
+ ((MediaOverviewActivity) getActivity()).onExitMultiSelect();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ getActivity().getWindow().setStatusBarColor(originalStatusBarColor);
+ }
+ }
+ }
}
- @Override
- public void onLoadFinished(@NonNull Loader loader, Cursor data) {
- ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data);
- getActivity().invalidateOptionsMenu();
+ public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment {
- this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
- }
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
+ MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
- @Override
- public void onLoaderReset(@NonNull Loader loader) {
- ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
- getActivity().invalidateOptionsMenu();
+ this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
+ this.noMedia = ViewUtil.findById(view, R.id.no_documents);
+
+ this.recyclerView.setAdapter(adapter);
+ this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), RecyclerView.VERTICAL, false));
+ this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
+
+ return view;
+ }
+
+ @Override
+ public @NonNull
+ Loader onCreateLoader(int id, Bundle args) {
+ return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
+ }
+
+ @Override
+ public void onLoadFinished(@NonNull Loader loader, Cursor data) {
+ ((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(data);
+ getActivity().invalidateOptionsMenu();
+
+ this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
+ }
+
+ @Override
+ public void onLoaderReset(@NonNull Loader loader) {
+ ((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(null);
+ getActivity().invalidateOptionsMenu();
+ }
}
- }
}
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 7635ad40e..52748b429 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
@@ -53,7 +53,7 @@ class ProfilePictureView @JvmOverloads constructor(
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
- if (recipient.isClosedGroupRecipient) {
+ if (recipient.isLegacyClosedGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(recipient.address.toGroupString(), true)
.sorted()
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 3a2b2cbb5..34e25af0b 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.fragment_contact_selection_closed_groups_title)) {
- it.address.isClosedGroup
+ it.address.isLegacyClosedGroup || it.address.isClosedGroup
}
}
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 000000000..49ce12673
--- /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 000000000..d55d0b249
--- /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 000000000..39c3461eb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt
@@ -0,0 +1,219 @@
+package org.thoughtcrime.securesms.conversation.settings
+
+import android.content.Intent
+import android.graphics.Typeface
+import android.os.Bundle
+import android.text.SpannableStringBuilder
+import android.text.style.StyleSpan
+import android.view.View
+import androidx.activity.viewModels
+import androidx.core.text.set
+import androidx.core.view.isVisible
+import dagger.hilt.android.AndroidEntryPoint
+import network.loki.messenger.R
+import network.loki.messenger.databinding.ActivityConversationSettingsBinding
+import org.session.libsignal.utilities.Log
+import org.thoughtcrime.securesms.MediaOverviewActivity
+import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
+import org.thoughtcrime.securesms.database.LokiThreadDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
+import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
+import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity
+import org.thoughtcrime.securesms.showSessionDialog
+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 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.addAdmins.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.note_to_self)
+ else -> recipient.toShortString()
+ }
+ // Setup group description (if group)
+ binding.conversationSubtitle.isVisible = recipient.isClosedGroupRecipient.apply {
+ binding.conversationSubtitle.text = viewModel.closedGroupInfo(recipient.address.serialize())?.description
+ }
+
+ // Toggle group-specific settings
+ val areGroupOptionsVisible = recipient.isClosedGroupRecipient || 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.conversation_settings_unpin_conversation
+ else R.string.conversation_settings_pin_conversation
+ )
+
+ // 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?) {
+ when {
+ v === binding.searchConversation -> {
+ setResult(RESULT_SEARCH)
+ finish()
+ }
+ v === binding.allMedia -> {
+ val threadRecipient = viewModel.recipient ?: return
+ val intent = Intent(this, MediaOverviewActivity::class.java).apply {
+ putExtra(MediaOverviewActivity.ADDRESS_EXTRA, threadRecipient.address)
+ }
+ startActivity(intent)
+ }
+ 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.dialog_clear_all_messages_title)
+ text(R.string.dialog_clear_all_messages_message)
+ destructiveButton(
+ R.string.dialog_clear_all_messages_clear,
+ R.string.dialog_clear_all_messages_clear) {
+ viewModel.clearMessages(false)
+ }
+ cancelButton()
+ }
+ }
+ v === binding.leaveGroup -> {
+ showSessionDialog {
+
+ title(R.string.conversation_settings_leave_group)
+
+ val name = viewModel.recipient!!.name!!
+ val text = getString(R.string.conversation_settings_leave_group_name)
+ val textWithArgs = getString(R.string.conversation_settings_leave_group_name, name)
+
+ // Searches for the start index of %1$s
+ val startIndex = """%1${"\\$"}s""".toRegex().find(text)?.range?.start
+ val endIndex = startIndex?.plus(name.length)
+
+ val styledText = if (startIndex == null || endIndex == null) {
+ textWithArgs
+ } else {
+ val boldName = SpannableStringBuilder(textWithArgs)
+ boldName[startIndex .. endIndex] = StyleSpan(Typeface.BOLD)
+ boldName
+ }
+ text(styledText)
+ destructiveButton(
+ R.string.conversation_settings_leave_group,
+ R.string.conversation_settings_leave_group
+ ) {
+ viewModel.leaveGroup()
+ }
+ }
+ }
+ v === binding.editGroup -> {
+ val recipient = viewModel.recipient ?: return
+
+ val intent = when {
+ recipient.isLegacyClosedGroupRecipient -> Intent(this, EditLegacyClosedGroupActivity::class.java).apply {
+ val groupID: String = recipient.address.toGroupString()
+ putExtra(EditLegacyClosedGroupActivity.groupIDKey, groupID)
+ }
+ recipient.isClosedGroupRecipient -> Intent(this, EditClosedGroupActivity::class.java).apply {
+ val groupID = recipient.address.serialize()
+ putExtra(EditClosedGroupActivity.groupIDKey, groupID)
+ }
+
+ 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 000000000..a79d94b3a
--- /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 000000000..619cb71dc
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt
@@ -0,0 +1,89 @@
+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.launch
+import network.loki.messenger.libsession_util.util.GroupDisplayInfo
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.utilities.Address
+import org.session.libsession.utilities.TextSecurePreferences
+
+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.isClosedGroupRecipient -> {
+ val group = storage.getLibSessionClosedGroup(recipient.address.serialize()) ?: return@let false
+ group.hasAdminKey()
+ }
+ 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(address: String): GroupDisplayInfo? = storage.getClosedGroupDisplayInfo(address)
+
+ fun leaveGroup() {
+ viewModelScope.launch {
+ storage.leaveGroup(recipient!!.address.serialize())
+ }
+ }
+
+ // 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/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
index 70d83ef5e..4730f147a 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
@@ -66,6 +66,7 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import nl.komponents.kovenant.ui.successUi
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
@@ -83,7 +84,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.SessionId
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
@@ -98,6 +98,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.SessionId
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext
@@ -105,6 +106,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
+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
@@ -141,7 +144,6 @@ import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
-import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -198,7 +200,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
- ConversationMenuHelper.ConversationMenuListener {
+ ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
private var binding: ActivityConversationV2Binding? = null
@@ -213,7 +215,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
@@ -224,6 +226,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()))
@@ -238,7 +247,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val sessionId = SessionId(it.serialize())
val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1))
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
- storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
+ storage.getOrCreateBlindedIdMapping(sessionId.hexString(), openGroup.server, openGroup.publicKey).sessionId?.let {
fromSerialized(it)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
} else {
@@ -361,7 +370,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
@@ -414,6 +423,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updatePlaceholder()
setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this)
+ binding!!.toolbarContent.profilePictureView.setOnClickListener(this)
updateSendAfterApprovalText()
showOrHideInputIfNeeded()
setUpMessageRequestsBar()
@@ -578,7 +588,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
recipient.isLocalNumber -> getString(R.string.note_to_self)
else -> recipient.toShortString()
}
- @DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) {
+ @DimenRes val sizeID: Int = if (viewModel.recipient?.isLegacyClosedGroupRecipient == true) {
R.dimen.medium_profile_picture_size
} else {
R.dimen.small_profile_picture_size
@@ -810,8 +820,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private fun showOrHideInputIfNeeded() {
- val recipient = viewModel.recipient
- if (recipient != null && recipient.isClosedGroupRecipient) {
+ val recipient = viewModel.recipient ?: return
+ if (recipient.isLegacyClosedGroupRecipient) {
val group = groupDb.getGroup(recipient.address.toGroupString()).orNull()
val isActive = (group?.isActive == true)
binding?.inputBar?.showInput = isActive
@@ -821,8 +831,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private fun setUpMessageRequestsBar() {
+ val recipient = viewModel.recipient ?: return
binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread()
binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread()
+ binding?.sendAcceptsTextView?.setText(
+ if (recipient.isClosedGroupRecipient) R.string.message_requests_send_group_notice
+ else R.string.message_requests_send_notice
+ )
binding?.acceptMessageRequestButton?.setOnClickListener {
acceptMessageRequest()
}
@@ -856,11 +871,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun isIncomingMessageRequestThread(): Boolean {
val recipient = viewModel.recipient ?: return false
- return !recipient.isGroupRecipient &&
+ return !recipient.isLegacyClosedGroupRecipient &&
+ !recipient.isOpenGroupRecipient &&
!recipient.isApproved &&
!recipient.isLocalNumber &&
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() &&
- threadDb.getMessageCount(viewModel.threadId) > 0
+ (threadDb.getMessageCount(viewModel.threadId) > 0 || recipient.isClosedGroupRecipient)
}
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
@@ -1116,14 +1132,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
}
} else if (recipient.isGroupRecipient) {
- viewModel.openGroup?.let { openGroup ->
- val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
- actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
- } ?: run {
- val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
- actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
+ when {
+ recipient.isOpenGroupRecipient -> {
+ viewModel.openGroup?.let { openGroup ->
+ val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
+ actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
+ }
+ }
+ recipient.isLegacyClosedGroupRecipient -> {
+ val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
+ actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
+ }
+ recipient.isClosedGroupRecipient -> {
+ val userCount = viewModel.closedGroupMembers.size
+ actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
+ }
}
- viewModel
} else {
actionBarBinding.conversationSubtitleView.isVisible = false
}
@@ -1140,6 +1164,13 @@ 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) {
showSessionDialog {
title(R.string.RecipientPreferenceActivity_block_this_contact_question)
@@ -1174,8 +1205,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
+ // TODO: don't need to allow new closed group check here, removed in new disappearing messages
override fun showExpiringMessagesDialog(thread: Recipient) {
- if (thread.isClosedGroupRecipient) {
+ if (thread.isLegacyClosedGroupRecipient) {
val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
if (group?.isActive == false) { return }
}
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 532e65e19..9b25db1d5 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
@@ -12,14 +12,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import network.loki.messenger.libsession_util.util.GroupMember
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
-import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
-import org.thoughtcrime.securesms.database.Storage
+import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID
@@ -28,7 +29,7 @@ class ConversationViewModel(
val threadId: Long,
val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
- private val storage: Storage
+ private val storage: StorageProtocol
) : ViewModel() {
val showSendAfterApprovalText: Boolean
@@ -58,19 +59,27 @@ class ConversationViewModel(
val openGroup: OpenGroup?
get() = _openGroup.value
+ val closedGroupMembers: List
+ get() {
+ val recipient = recipient ?: return emptyList()
+ if (!recipient.isClosedGroupRecipient) return emptyList()
+ return storage.getMembers(recipient.address.serialize())
+ }
+
+
val serverCapabilities: List
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
- ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
+ ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString()
}
val isMessageRequestThread : Boolean
get() {
val recipient = recipient ?: return false
- return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
+ return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isOpenGroupRecipient && !recipient.isApproved
}
val canReactToMessages: Boolean
@@ -186,7 +195,8 @@ class ConversationViewModel(
}
fun declineMessageRequest() {
- repository.declineMessageRequest(threadId)
+ val recipient = recipient ?: return
+ repository.declineMessageRequest(threadId, recipient)
}
private fun showMessage(message: String) {
@@ -228,7 +238,7 @@ class ConversationViewModel(
@Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
- private val storage: Storage
+ private val storage: StorageProtocol
) : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
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 b6212b854..c05f5d926 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 =
+ getString(R.string.delete_message_for_my_devices)
+ } else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
binding.deleteForEveryoneTextView.text =
resources.getString(R.string.delete_message_for_me_and_recipient, contact)
}
- 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 61732827f..c316a2ae5 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
@@ -55,6 +55,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.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage
@@ -81,7 +82,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 5edd63f10..fd49e501b 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
@@ -9,44 +9,53 @@ import android.text.style.StyleSpan
import androidx.fragment.app.DialogFragment
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.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
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 sessionID = recipient.address.toString()
- val contact = contactDB.getContactWithSessionID(sessionID)
- val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
- title(resources.getString(R.string.dialog_download_title, name))
+ val threadId = storage.getThreadId(threadRecipient) ?: run {
+ dismiss()
+ return@createSessionDialog
+ }
- val explanation = resources.getString(R.string.dialog_download_explanation, name)
+ val displayName = when {
+ threadRecipient.isOpenGroupRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN"
+ threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN"
+ threadRecipient.isClosedGroupRecipient -> threadRecipient.name ?: "UNKNOWN"
+ else -> storage.getContactWithSessionID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN"
+ }
+ title(resources.getString(R.string.dialog_auto_download_title))
+
+ val explanation = resources.getString(R.string.dialog_auto_download_explanation, displayName)
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.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() }
- cancelButton { dismiss() }
+ button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) {
+ setAutoDownload(true)
+ }
+ cancelButton {
+ setAutoDownload(false)
+ }
}
- private fun trust() {
- val sessionID = recipient.address.toString()
- val contact = contactDB.getContactWithSessionID(sessionID) ?: return
- val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
- contactDB.setContactIsTrusted(contact, true, threadID)
- JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
- dismiss()
+ private fun setAutoDownload(shouldDownload: Boolean) {
+ storage.setAutoDownloadAttachments(threadRecipient, shouldDownload)
}
}
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 3746aa52e..7c94a6230 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.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
+import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
- ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
+ ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString()
fun userCanDeleteSelectedItems(): Boolean {
val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
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 02ee4ae45..b855e411a 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
@@ -38,8 +38,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.EditLegacyClosedGroupActivity
+import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog
@@ -63,7 +63,7 @@ object ConversationMenuHelper {
// Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages
- if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) {
+ if (!isOpenGroup && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient) && !thread.isBlocked) {
if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages)
@@ -92,7 +92,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
@@ -280,15 +280,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, EditLegacyClosedGroupActivity::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/PendingAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt
new file mode 100644
index 000000000..afdf84240
--- /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 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.jobs.JobQueue
+import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
+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.createAndStartAttachmentDownload
+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.Slide_audio
+ AttachmentType.DOCUMENT -> R.string.document
+ AttachmentType.IMAGE -> R.string.image
+ AttachmentType.VIDEO -> R.string.video
+ }
+
+ val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).lowercase(Locale.ROOT))
+
+ binding.pendingDownloadIcon.setColorFilter(textColor)
+ binding.pendingDownloadSize.text = attachment.displaySize()
+ binding.pendingDownloadTitle.text = text
+ }
+ // endregion
+
+ // region Interaction
+ fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) {
+ JobQueue.shared.createAndStartAttachmentDownload(attachment)
+ if (!storage.hasAutoDownloadFlagBeenSet(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 47034cf8e..000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt
+++ /dev/null
@@ -1,51 +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 network.loki.messenger.R
-import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding
-import org.session.libsession.utilities.recipients.Recipient
-import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog
-import org.thoughtcrime.securesms.util.ActivityDispatcher
-import java.util.Locale
-
-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.Slide_audio
- AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.document
- AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media
- }
- val iconDrawable = ContextCompat.getDrawable(context,iconRes)!!
- iconDrawable.mutate().setTint(textColor)
- val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).toLowerCase(Locale.ROOT))
-
- 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 c812d0f73..1e47ba075 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
@@ -64,7 +64,6 @@ class VisibleMessageContentView : ConstraintLayout {
glide: GlideRequests = GlideApp.with(this),
thread: Recipient,
searchQuery: String? = null,
- contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (Long, Long) -> Unit,
suppressThumbnails: Boolean = false
) {
@@ -74,8 +73,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()
@@ -88,7 +88,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
@@ -103,9 +102,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
@@ -151,6 +150,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) }
@@ -158,10 +158,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)
@@ -170,26 +171,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(
@@ -207,13 +220,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() }
}
}
@@ -250,7 +272,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 9538148fd..352191339 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
@@ -267,7 +267,6 @@ class VisibleMessageView : LinearLayout {
glide,
thread,
searchQuery,
- message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
onAttachmentNeedsDownload
)
binding.messageContentView.root.delegate = delegate
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt
index ee1c7257c..b2803b2c5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt
@@ -11,23 +11,31 @@ object MentionManagerUtilities {
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
val result = mutableSetOf()
val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return
- if (recipient.address.isClosedGroup) {
- val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
- result.addAll(members)
- } else {
- val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
- val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
- var record: MessageRecord? = reader.next
- while (record != null) {
- result.add(record.individualRecipient.address.serialize())
- try {
- record = reader.next
- } catch (exception: Exception) {
- record = null
- }
+ val storage = DatabaseComponent.get(context).storage()
+ when {
+ recipient.address.isLegacyClosedGroup -> {
+ val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
+ result.addAll(members)
+ }
+ recipient.address.isClosedGroup -> {
+ val members = storage.getMembers(recipient.address.serialize())
+ result.addAll(members.map { it.sessionId })
+ }
+ recipient.address.isOpenGroup -> {
+ val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
+ val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
+ var record: MessageRecord? = reader.next
+ while (record != null) {
+ result.add(record.individualRecipient.address.serialize())
+ try {
+ record = reader.next
+ } catch (exception: Exception) {
+ record = null
+ }
+ }
+ reader.close()
+ result.add(TextSecurePreferences.getLocalNumber(context)!!)
}
- reader.close()
- result.add(TextSecurePreferences.getLocalNumber(context)!!)
}
MentionsManager.userPublicKeyCache[threadID] = result
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java
index 45172e2f6..cd098777d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java
@@ -966,10 +966,6 @@ public class AttachmentDatabase extends Database {
@SuppressLint("NewApi")
private ThumbnailData generateVideoThumbnail(AttachmentId attachmentId) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Video thumbnails not supported...");
- return null;
- }
DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA);
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 19a511bfd..43a3e115f 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.SessionId
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: SessionId) {
+ 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/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
index 1b273de92..134ea6e45 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/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
index 111b6d536..8555b248d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
@@ -826,23 +826,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
@@ -926,6 +909,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, 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, true)
+ notifyConversationListeners(threadId)
+ notifyStickerListeners()
+ notifyStickerPackListeners()
+ }
+
private fun getSerializedSharedContacts(
insertedAttachmentIds: Map,
contacts: List
@@ -1069,7 +1108,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false
}
- /*package*/
private fun deleteThreads(threadIds: Set) {
val db = databaseHelper.writableDatabase
val where = StringBuilder()
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 dde5847ff..76b8cc1c3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
@@ -64,13 +64,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, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
- UNIDENTIFIED_ACCESS_MODE,
- FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
+ UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH,
+ BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD,
};
static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@@ -109,6 +110,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.sessionID+" "+
+ "FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))";
+ }
+
public static String getCreateApprovedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
@@ -178,30 +190,31 @@ 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 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(SIGNAL_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 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(SIGNAL_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;
@@ -225,7 +238,7 @@ public class RecipientDatabase extends Database {
}
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
- notifyType,
+ notifyType, autoDownloadAttachments,
Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
@@ -238,6 +251,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());
@@ -313,6 +342,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 49a633936..1e985d141 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
@@ -3,17 +3,16 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
-import androidx.core.database.getStringOrNull
import org.session.libsession.messaging.contacts.Contact
-import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
+import org.session.libsignal.utilities.SessionId
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 sessionID = "session_id"
const val name = "name"
const val nickname = "nickname"
@@ -74,23 +73,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, "$sessionID = ?", arrayOf( contact.sessionID ))
notifyConversationListListeners()
}
fun contactFromCursor(cursor: Cursor): Contact {
- val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
+ val sessionID = cursor.getString(sessionID)
val contact = Contact(sessionID)
- 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 {
+ 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 591755b88..c400a7e71 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt
@@ -7,6 +7,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
@@ -73,6 +74,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 4ef576f40..e48f27d49 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -685,11 +685,16 @@ public class SmsDatabase extends MessagingDatabase {
}
}
- /*package */void deleteThread(long threadId) {
+ void deleteThread(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
}
+ void deleteMessagesFrom(long threadId, String fromUser) {
+ SQLiteDatabase db = databaseHelper.getWritableDatabase();
+ db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
+ }
+
/*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
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 481a4fa06..140367e24 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
@@ -2,18 +2,26 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
-import network.loki.messenger.libsession_util.ConfigBase
+import com.google.protobuf.ByteString
+import network.loki.messenger.libsession_util.Config
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.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.KeyPair
import network.loki.messenger.libsession_util.util.UserPic
+import nl.komponents.kovenant.functional.bind
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping
@@ -22,7 +30,9 @@ 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
@@ -31,6 +41,7 @@ import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.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
@@ -46,18 +57,21 @@ 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.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI
+import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.SnodeAPI.signingKeyCallback
+import org.session.libsession.snode.SnodeMessage
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord
@@ -71,17 +85,26 @@ 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.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.SessionId
import org.session.libsignal.utilities.guava.Optional
+import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId
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.dependencies.Toaster
import org.thoughtcrime.securesms.groups.ClosedGroupManager
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.OpenGroupManager
@@ -90,8 +113,15 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
+import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember
-open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol,
+open class Storage(
+ context: Context,
+ helper: SQLCipherOpenHelper,
+ private val configFactory: ConfigFactory,
+ private val pollerFactory: PollerFactory,
+ private val toaster: Toaster,
+) : Database(context, helper), StorageProtocol,
ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) {
@@ -101,20 +131,31 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
- if (address.isClosedGroup) {
- val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
- val closedGroup = getGroup(address.toGroupString())
- if (closedGroup != null && closedGroup.isActive) {
- val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
- groups.set(legacyGroup)
- val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
- lastRead = SnodeAPI.nowWithOffset,
- )
- volatile.set(newVolatileParams)
+ when {
+ address.isLegacyClosedGroup -> {
+ val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
+ val closedGroup = getGroup(address.toGroupString())
+ if (closedGroup != null && closedGroup.isActive) {
+ val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
+ groups.set(legacyGroup)
+ val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
+ lastRead = SnodeAPI.nowWithOffset,
+ )
+ volatile.set(newVolatileParams)
+ }
+ }
+ address.isClosedGroup -> {
+ val sessionId = address.serialize()
+ groups.getClosedGroup(sessionId) ?: return Log.d("Closed group doesn't exist locally", NullPointerException())
+ val conversation = Conversation.ClosedGroup(
+ sessionId, 0, false
+ )
+ volatile.set(conversation)
+ }
+ address.isOpenGroup -> {
+ // these should be added on the group join / group info fetch
+ Log.w("Loki", "Thread created called for open group address, not adding any extra information")
}
- } else if (address.isOpenGroup) {
- // these should be added on the group join / group info fetch
- Log.w("Loki", "Thread created called for open group address, not adding any extra information")
}
} else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
@@ -123,11 +164,11 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
- priority = ConfigBase.PRIORITY_VISIBLE
+ priority = PRIORITY_VISIBLE
}
} else {
val userProfile = configFactory.user ?: return
- userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE)
+ userProfile.setNtsPriority(PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
}
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
@@ -139,13 +180,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
- if (address.isClosedGroup) {
+ if (address.isLegacyClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
} else if (address.isOpenGroup) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
+ } else if (address.isClosedGroup) {
+ 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
@@ -246,7 +289,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
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.isClosedGroupRecipient -> config.getOrConstructClosedGroup(recipient.address.serialize())
// recipient is open group
recipient.isOpenGroupRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
@@ -294,6 +338,9 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
?.let { SodiumUtilities.sessionId(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))
@@ -306,7 +353,14 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
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
}
@@ -423,7 +477,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
}
- override fun notifyConfigUpdates(forConfigObject: ConfigBase) {
+ override fun notifyConfigUpdates(forConfigObject: Config) {
notifyUpdates(forConfigObject)
}
@@ -439,12 +493,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
return configFactory.user?.getCommunityMessageRequests() == true
}
- fun notifyUpdates(forConfigObject: ConfigBase) {
+ private fun notifyUpdates(forConfigObject: Config) {
when (forConfigObject) {
is UserProfile -> updateUser(forConfigObject)
is Contacts -> updateContacts(forConfigObject)
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject)
is UserGroupsConfig -> updateUserGroups(forConfigObject)
+ is GroupInfoConfig -> updateGroupInfo(forConfigObject)
+ is GroupKeysConfig -> updateGroupKeys(forConfigObject)
+ is GroupMembersConfig -> updateGroupMembers(forConfigObject)
}
}
@@ -481,6 +538,22 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
+ private fun updateGroupInfo(groupInfoConfig: GroupInfoConfig) {
+ val threadId = getOrCreateThreadIdFor(Address.fromSerialized(groupInfoConfig.id().hexString()))
+ val recipient = getRecipientForThread(threadId) ?: return
+ val db = DatabaseComponent.get(context).recipientDatabase()
+ db.setProfileName(recipient, groupInfoConfig.getName())
+ // 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) {
val extracted = contacts.all().toList()
addLibSessionContacts(extracted)
@@ -510,6 +583,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false)
is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false)
is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
+ is Conversation.ClosedGroup -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) // New groups will be managed bia libsession
}
if (threadId != null) {
if (conversation.lastRead > getLastSeen(threadId)) {
@@ -537,9 +611,9 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } }
val existingJoinUrls = existingCommunities.values.map { it.joinURL }
- val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
- val lgcIds = lgc.map { it.sessionId }
- val toDeleteClosedGroups = existingClosedGroups.filter { group ->
+ val existingLegacyClosedGroups = getAllGroups(includeInactive = true).filter { it.isLegacyClosedGroup }
+ val lgcIds = lgc.map { it.sessionId.hexString() }
+ val toDeleteClosedGroups = existingLegacyClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
@@ -571,8 +645,21 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
}
+ val newClosedGroups = userGroups.allClosedGroupInfo()
+ for (closedGroup in newClosedGroups) {
+ val recipient = Recipient.from(context, Address.fromSerialized(closedGroup.groupSessionId.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.groupSessionId)?.start()
+ }
+ }
+ // TODO: add in removing legacy closed groups via config update
+
for (group in lgc) {
- val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
+ val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId.hexString() }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
@@ -586,28 +673,28 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} else {
val members = group.members.keys.map { Address.fromSerialized(it) }
val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) }
- val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
+ val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId.hexString())
val title = group.name
val formationTimestamp = (group.joinedAt * 1000L)
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for
- addClosedGroupPublicKey(group.sessionId)
+ addClosedGroupPublicKey(group.sessionId.hexString())
// Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
- addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
+ addClosedGroupEncryptionKeyPair(keyPair, group.sessionId.hexString(), SnodeAPI.nowWithOffset)
// Set expiration timer
val expireTimer = group.disappearingTimer
setExpirationTimer(groupId, expireTimer.toInt())
// Notify the PN server
- PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
+ PushRegistryV1.subscribeGroup(group.sessionId.hexString(), publicKey = localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update
// Start polling
- ClosedGroupPollerV2.shared.startPolling(group.sessionId)
+ LegacyClosedGroupPollerV2.shared.startPolling(group.sessionId.hexString())
}
}
}
@@ -847,6 +934,127 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
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 = group.adminKey
+ userGroups.set(group)
+ val groupInfo = configFactory.getGroupInfoConfig(group.groupSessionId) ?: return Optional.absent()
+ val groupMembers = configFactory.getGroupMemberConfig(group.groupSessionId) ?: return Optional.absent()
+
+ with (groupInfo) {
+ setName(groupName)
+ setDescription(groupDescription)
+ }
+
+ groupMembers.set(
+ LibSessionGroupMember(ourSessionId, getUserProfile().displayName, admin = true)
+ )
+
+ members.forEach { groupMembers.set(LibSessionGroupMember(it.sessionID, it.name, invitePending = true)) }
+
+ val groupKeys = configFactory.constructGroupKeysConfig(group.groupSessionId,
+ info = groupInfo,
+ members = groupMembers) ?: return Optional.absent()
+
+ val newGroupRecipient = group.groupSessionId.hexString()
+ val configTtl = 14 * 24 * 60 * 60 * 1000L
+ // Test the sending
+ val keyPush = groupKeys.pendingConfig() ?: return Optional.absent()
+
+ val keysSnodeMessage = SnodeMessage(
+ newGroupRecipient,
+ Base64.encodeBytes(keyPush),
+ configTtl,
+ groupCreationTimestamp
+ )
+ val keysBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ groupKeys.namespace(),
+ keysSnodeMessage,
+ adminKey
+ )
+
+ val (infoPush, infoSeqNo) = groupInfo.push()
+ val infoSnodeMessage = SnodeMessage(
+ newGroupRecipient,
+ Base64.encodeBytes(infoPush),
+ configTtl,
+ groupCreationTimestamp
+ )
+ val infoBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ groupInfo.namespace(),
+ infoSnodeMessage,
+ adminKey
+ )
+
+ val (memberPush, memberSeqNo) = groupMembers.push()
+ val memberSnodeMessage = SnodeMessage(
+ newGroupRecipient,
+ Base64.encodeBytes(memberPush),
+ configTtl,
+ groupCreationTimestamp
+ )
+ val memberBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ groupMembers.namespace(),
+ memberSnodeMessage,
+ adminKey
+ )
+
+ 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)
+ Log.d("Group Config", "Saved group config for $newGroupRecipient")
+ groupKeys.free()
+ groupInfo.free()
+ groupMembers.free()
+ val groupRecipient = Recipient.from(context, fromSerialized(newGroupRecipient), false)
+ setRecipientApprovedMe(groupRecipient, true)
+ setRecipientApproved(groupRecipient, true)
+ pollerFactory.updatePollers()
+
+ val memberArray = members.map(Contact::sessionID).toTypedArray()
+ val job = InviteContactsJob(group.groupSessionId.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)
+ }
+
+ return Optional.absent()
+ }
+
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) {
val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return
@@ -854,10 +1062,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo(
- sessionId = groupPublicKey,
+ sessionId = SessionId.from(groupPublicKey),
name = name,
members = members,
- priority = ConfigBase.PRIORITY_VISIBLE,
+ priority = PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = 0L,
@@ -893,7 +1101,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(),
- priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
+ priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE,
disappearingTimer = recipientSettings.expireMessages.toLong(),
joinedAt = (existingGroup.formationTimestamp / 1000L)
)
@@ -928,7 +1136,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
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, 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)
}
@@ -946,10 +1154,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
mmsDB.markAsSent(infoMessageID, true)
}
- 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 {
@@ -1013,6 +1219,554 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
}
+ override fun getMembers(groupPublicKey: String): List =
+ configFactory.getGroupMemberConfig(SessionId.from(groupPublicKey))?.use { it.all() }?.toList() ?: emptyList()
+
+ override fun respondToClosedGroupInvitation(groupRecipient: Recipient, approved: Boolean) {
+ val groups = configFactory.userGroups ?: return
+ val groupSessionId = SessionId.from(groupRecipient.address.serialize())
+ if (!approved) {
+ groups.eraseClosedGroup(groupSessionId.hexString())
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ return
+ } else {
+ val closedGroupInfo = groups.getClosedGroup(groupSessionId.hexString())?.copy(
+ invited = false
+ ) ?: return
+ groups.set(closedGroupInfo)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ pollerFactory.pollerFor(groupSessionId)?.start()
+ val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder()
+ .setIsApproved(true)
+ val responseData = GroupUpdateMessage.newBuilder()
+ .setInviteResponse(inviteResponse)
+ val responseMessage = GroupUpdated(responseData.build())
+ // this will fail the first couple of times :)
+ MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString()))
+ }
+ }
+
+ override fun addClosedGroupInvite(
+ groupId: SessionId,
+ name: String,
+ authData: ByteArray,
+ invitingAdmin: SessionId
+ ) {
+ val recipient = Recipient.from(context, fromSerialized(groupId.hexString()), false)
+ val profileManager = SSKEnvironment.shared.profileManager
+ val groups = configFactory.userGroups ?: return
+ val shouldAutoApprove = false //TESTING// getRecipientApproved(fromSerialized(invitingAdmin.hexString()))
+ val closedGroupInfo = GroupInfo.ClosedGroupInfo(
+ groupId,
+ byteArrayOf(),
+ authData,
+ PRIORITY_VISIBLE,
+ !shouldAutoApprove,
+ )
+ groups.set(closedGroupInfo)
+ configFactory.persist(groups, SnodeAPI.nowWithOffset)
+ profileManager.setName(context, recipient, name)
+ getOrCreateThreadIdFor(recipient.address)
+ setRecipientApprovedMe(recipient, true)
+ setRecipientApproved(recipient, shouldAutoApprove)
+ if (shouldAutoApprove) {
+ pollerFactory.pollerFor(groupId)?.start()
+ val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder()
+ .setIsApproved(true)
+ val responseData = GroupUpdateMessage.newBuilder()
+ .setInviteResponse(inviteResponse)
+ val responseMessage = GroupUpdated(responseData.build())
+ // this will fail the first couple of times :)
+ MessageSender.send(responseMessage, fromSerialized(groupId.hexString()))
+ }
+ }
+
+ override fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: SessionId) {
+ // 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.copy(invitePending = false))
+ } 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(SessionId.from(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 sessionId = SessionId.from(groupSessionId)
+ val membersConfig = configFactory.getGroupMemberConfig(sessionId) ?: return
+ val infoConfig = configFactory.getGroupInfoConfig(sessionId) ?: return
+
+ // 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 = getContactWithSessionID(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,
+ invitePending = true,
+ )
+ membersConfig.set(member)
+ }
+
+ // re-key for new members
+ val keysConfig = configFactory.getGroupKeysConfig(
+ sessionId,
+ info = infoConfig,
+ members = membersConfig,
+ free = false
+ ) ?: return
+
+ keysConfig.rekey(infoConfig, membersConfig)
+
+ // build unrevocation, in case of re-adding members
+ val unrevocation = SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest(
+ groupSessionId,
+ adminKey,
+ filteredMembers.map { keysConfig.getSubAccountToken(SessionId.from(it)) }.toTypedArray()
+ ) ?: return Log.e("ClosedGroup", "Failed to build revocation update")
+
+ // Build and store the key update in group swarm
+ val toDelete = mutableListOf()
+
+ val signCallback = signingKeyCallback(adminKey)
+
+ val keyMessage = keysConfig.messageInformation(groupSessionId, adminKey)
+ val infoMessage = infoConfig.messageInformation(toDelete, groupSessionId, adminKey)
+ val membersMessage = membersConfig.messageInformation(toDelete, groupSessionId, adminKey)
+
+ val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
+ groupSessionId,
+ toDelete,
+ signCallback
+ )
+
+ val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch)
+
+ val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode ->
+ SnodeAPI.getRawBatchResponse(
+ snode,
+ groupSessionId,
+ stores + unrevocation + delete,
+ 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 messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.ADDED.name}$timestamp"
+ val signature = SodiumUtilities.sign(messageToSign.toByteArray(), 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, sessionId)
+ 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: SessionId) {
+ val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset
+ val senderPublicKey = message.sender
+ val userPublicKey = getUserPublicKey()!!
+ val updateData = UpdateMessageData.buildGroupUpdate(message)?.toJSON() ?: return
+
+ if (senderPublicKey == null || senderPublicKey == userPublicKey) {
+ val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString()), false)
+ val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, closedGroup.hexString(), null, sentTimestamp, 0, true, null, listOf(), listOf())
+ val mmsDB = DatabaseComponent.get(context).mmsDatabase()
+ val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase()
+ if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return
+ val threadDb = DatabaseComponent.get(context).threadDatabase()
+ val threadID = threadDb.getThreadIdIfExistsFor(recipient)
+ val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true)
+ mmsDB.markAsSent(infoMessageID, true)
+ } else {
+ val group = SignalServiceGroup(Hex.fromStringCondensed(closedGroup.hexString()), SignalServiceGroup.GroupType.SIGNAL)
+ val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false)
+ val infoMessage = IncomingGroupMessage(m, updateData, true)
+ val smsDB = DatabaseComponent.get(context).smsDatabase()
+ smsDB.insertMessageInbox(infoMessage, true)
+ }
+ }
+
+ override fun promoteMember(groupSessionId: String, promotions: Array) {
+ val closedGroupId = SessionId.from(groupSessionId)
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
+ if (adminKey.isEmpty()) {
+ return Log.e("ClosedGroup", "No admin key for group")
+ }
+ val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
+ val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
+ val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
+
+ promotions.forEach { sessionId ->
+ val promoted = members.get(sessionId)?.copy(
+ promotionPending = true,
+ ) ?: return@forEach
+ members.set(promoted)
+
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setPromoteMessage(
+ DataMessage.GroupUpdatePromoteMessage.newBuilder()
+ .setGroupIdentitySeed(ByteString.copyFrom(adminKey))
+ )
+ .build()
+ )
+ MessageSender.send(message, fromSerialized(sessionId))
+ }
+ configFactory.saveGroupConfigs(keys, info, members)
+ info.free()
+ members.free()
+ keys.free()
+ val groupDestination = Destination.ClosedGroup(groupSessionId)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
+ val timestamp = SnodeAPI.nowWithOffset
+ val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.PROMOTED.name}$timestamp"
+ val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberChangeMessage(
+ GroupUpdateMemberChangeMessage.newBuilder()
+ .addAllMemberSessionIds(promotions.toList())
+ .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ ).apply {
+ sentTimestamp = timestamp
+ }
+ MessageSender.send(message, fromSerialized(groupSessionId))
+ insertGroupInfoChange(message, closedGroupId)
+ }
+
+ override fun removeMember(groupSessionId: String, removedMembers: Array, fromDelete: Boolean) {
+ val closedGroupId = SessionId.from(groupSessionId)
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
+ if (adminKey.isEmpty()) {
+ return Log.e("ClosedGroup", "No admin key for group")
+ }
+ val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
+ val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
+ val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
+
+ removedMembers.forEach { sessionId ->
+ members.erase(sessionId)
+ }
+
+ // Re-key for removed members
+ keys.rekey(info, members)
+
+ val revocation = SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest(
+ groupSessionId,
+ adminKey,
+ removedMembers.map { keys.getSubAccountToken(SessionId.from(it)) }.toTypedArray()
+ ) ?: return Log.e("ClosedGroup", "Failed to build revocation update")
+
+ keys.rekey(info, members)
+
+ val toDelete = mutableListOf()
+
+ val keyMessage = keys.messageInformation(groupSessionId, adminKey)
+ val infoMessage = info.messageInformation(toDelete, groupSessionId, adminKey)
+ val membersMessage = members.messageInformation(toDelete, groupSessionId, adminKey)
+
+ val signCallback = signingKeyCallback(adminKey)
+
+ val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
+ groupSessionId,
+ toDelete,
+ signCallback
+ )
+
+ val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch)
+
+ val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode ->
+ SnodeAPI.getRawBatchResponse(
+ snode,
+ groupSessionId,
+ stores + revocation + delete,
+ sequence = true
+ )
+ }
+
+ try {
+ // handle new key update and revocations response
+ 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 revoke and key update: ${results["body"] as? String}")
+ }
+
+ configFactory.saveGroupConfigs(keys, info, members)
+ info.free()
+ members.free()
+ keys.free()
+
+ val timestamp = SnodeAPI.nowWithOffset
+ val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp"
+ val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
+ val updateMessage = GroupUpdateMessage.newBuilder()
+ .setMemberChangeMessage(
+ GroupUpdateMemberChangeMessage.newBuilder()
+ .addAllMemberSessionIds(removedMembers.toList())
+ .setType(GroupUpdateMemberChangeMessage.Type.REMOVED)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ val message = GroupUpdated(
+ updateMessage
+ ).apply { sentTimestamp = timestamp }
+ val groupDestination = Destination.ClosedGroup(groupSessionId)
+ MessageSender.send(message, groupDestination, false)
+ insertGroupInfoChange(message, closedGroupId)
+ } catch (e: Exception) {
+ info.free()
+ members.free()
+ keys.free()
+ }
+ }
+
+ override fun handlePromoted(keyPair: KeyPair) {
+ val closedGroupId = SessionId(IdPrefix.GROUP, keyPair.pubKey)
+ val ourSessionId = getUserPublicKey()!!
+ val userGroups = configFactory.userGroups ?: return
+ val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString())
+ ?: return Log.w("ClosedGroup", "No closed group in user groups matching promoted message")
+
+ val modified = closedGroup.copy(adminKey = keyPair.secretKey, authData = byteArrayOf())
+ userGroups.set(modified)
+ configFactory.scheduleUpdate(Destination.from(fromSerialized(getUserPublicKey()!!)))
+ val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
+ val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
+ val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
+ val ourMember = members.get(ourSessionId)?.copy(
+ admin = true,
+ promotionPending = false,
+ promotionFailed = false
+ ) ?: return Log.e("ClosedGroup", "We aren't a member in the closed group")
+ members.set(ourMember)
+ configFactory.saveGroupConfigs(keys, info, members)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.ClosedGroup(closedGroupId.hexString()))
+ info.free()
+ members.free()
+ keys.free()
+ }
+
+ override fun handleMemberLeft(message: GroupUpdated, closedGroupId: SessionId) {
+ 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
+ val adminKey = closedGroup.adminKey
+ val signCallback = signingKeyCallback(adminKey)
+ val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
+ val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
+ val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
+ members.erase(message.sender!!)
+
+ keys.rekey(info, members)
+
+ val toDelete = mutableListOf()
+
+ val keyMessage = keys.messageInformation(closedGroupHexString, adminKey)
+ val infoMessage = info.messageInformation(toDelete, closedGroupHexString, adminKey)
+ val membersMessage = members.messageInformation(toDelete, closedGroupHexString, adminKey)
+
+ val revocation = SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest(
+ closedGroupHexString,
+ adminKey,
+ arrayOf(message.sender!!.let { keys.getSubAccountToken(SessionId.from(it)) })
+ ) ?: return Log.e("ClosedGroup", "Failed to build revocation update")
+
+ val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
+ closedGroupHexString,
+ toDelete,
+ signCallback
+ )
+
+ val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch)
+
+ val response = SnodeAPI.getSingleTargetSnode(closedGroupHexString).bind { snode ->
+ SnodeAPI.getRawBatchResponse(
+ snode,
+ closedGroupHexString,
+ stores + revocation + delete,
+ 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 revoke and key update: ${results["body"] as? String}")
+ }
+
+ configFactory.saveGroupConfigs(keys, info, members)
+
+ val timestamp = SnodeAPI.nowWithOffset
+ val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp"
+ val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
+ val updatedMessage = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberChangeMessage(
+ GroupUpdateMemberChangeMessage.newBuilder()
+ .addAllMemberSessionIds(listOf(message.sender!!))
+ .setType(GroupUpdateMemberChangeMessage.Type.REMOVED)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ ).apply { this.sentTimestamp = timestamp }
+ MessageSender.send(updatedMessage, fromSerialized(closedGroupHexString))
+ insertGroupInfoChange(updatedMessage, closedGroupId)
+ info.free()
+ members.free()
+ keys.free()
+ } catch (e: Exception) {
+ Log.e("ClosedGroup", "Failed to store new key", e)
+ info.free()
+ members.free()
+ keys.free()
+ // toaster toast here
+ return
+ }
+ }
+
+ insertGroupInfoChange(message, closedGroupId)
+ }
+
+ override fun leaveGroup(groupSessionId: String) {
+ val closedGroupId = SessionId.from(groupSessionId)
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance())
+ .build()
+ )
+ try {
+ MessageSender.sendNonDurably(message, fromSerialized(groupSessionId), false).get()
+ pollerFactory.pollerFor(closedGroupId)?.stop()
+ // TODO: unsub from pushes
+ 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")
+ }
+ }
+
+ override fun setName(groupSessionId: String, newName: String) {
+ val closedGroupId = SessionId.from(groupSessionId)
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
+ if (adminKey.isEmpty()) {
+ return Log.e("ClosedGroup", "No admin key for group")
+ }
+ val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
+ val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
+ val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
+
+ info.setName(newName)
+
+ configFactory.saveGroupConfigs(keys, info, members)
+ info.free()
+ members.free()
+ keys.free()
+ val groupDestination = Destination.ClosedGroup(groupSessionId)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
+ val timestamp = SnodeAPI.nowWithOffset
+ val messageToSign = "INFO_CHANGE${GroupUpdateInfoChangeMessage.Type.NAME.name}$timestamp"
+ val signature = SodiumUtilities.sign(messageToSign.toByteArray(), 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 setServerCapabilities(server: String, capabilities: List) {
return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
}
@@ -1074,10 +1828,14 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
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)
@@ -1139,6 +1897,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
return if (recipientSettings.isPresent) { recipientSettings.get() } else null
}
+ override fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean {
+ return DatabaseComponent.get(context).recipientDatabase().isAutoDownloadFlagSet(recipient)
+ }
+
override fun addLibSessionContacts(contacts: List) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
@@ -1226,6 +1988,18 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
}
+ 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)
@@ -1257,27 +2031,36 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) {
val user = configFactory.user ?: return
- user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE)
+ user.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
} else if (threadRecipient.isContactRecipient) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(threadRecipient.address.serialize()) {
- priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
+ priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
}
} else if (threadRecipient.isGroupRecipient) {
val groups = configFactory.userGroups ?: return
- if (threadRecipient.isClosedGroupRecipient) {
- val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
- val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
- priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
- )
- groups.set(newGroupInfo)
- } else if (threadRecipient.isOpenGroupRecipient) {
- val openGroup = getOpenGroup(threadID) ?: return
- val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
- val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
- priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
- )
- groups.set(newGroupInfo)
+ when {
+ threadRecipient.isLegacyClosedGroupRecipient -> {
+ val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
+ val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
+ priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
+ )
+ groups.set(newGroupInfo)
+ }
+ threadRecipient.isClosedGroupRecipient -> {
+ val newGroupInfo = groups.getOrConstructClosedGroup(threadRecipient.address.serialize()).copy (
+ priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
+ )
+ groups.set(newGroupInfo)
+ }
+ threadRecipient.isOpenGroupRecipient -> {
+ val openGroup = getOpenGroup(threadID) ?: return
+ val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
+ val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
+ priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
+ )
+ groups.set(newGroupInfo)
+ }
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
@@ -1324,6 +2107,27 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
}
+ 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) {
+ smsDb.deleteThread(threadID)
+ mmsDb.deleteThread(threadID) // threadDB update called from within
+ } else {
+ smsDb.deleteMessagesFrom(threadID, fromUser.serialize())
+ mmsDb.deleteMessagesFrom(threadID, fromUser.serialize())
+ threadDb.update(threadID, false, true)
+ }
+ 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)
}
@@ -1463,7 +2267,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
override fun getRecipientApproved(address: Address): Boolean {
- return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
+ return address.isClosedGroup || DatabaseComponent.get(context).recipientDatabase().getApproved(address)
}
override fun setRecipientApproved(recipient: Recipient, approved: Boolean) {
@@ -1535,8 +2339,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
getAllContacts().forEach { contact ->
val sessionId = SessionId(contact.sessionID)
- if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) {
- val contactMapping = mapping.copy(sessionId = sessionId.hexString)
+ if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString(), blindedId, serverPublicKey)) {
+ val contactMapping = mapping.copy(sessionId = sessionId.hexString())
db.addBlindedIdMapping(contactMapping)
return contactMapping
}
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 921b1c06b..7b5db7d21 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.OPEN_GROUP_PREFIX;
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
@@ -439,32 +439,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;
@@ -503,13 +477,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 '" + OPEN_GROUP_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 '" + OPEN_GROUP_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";
@@ -771,6 +747,8 @@ public class ThreadDatabase extends Database {
if (shouldDeleteEmptyThread) {
deleteThread(threadId);
return true;
+ } else {
+ updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
}
return false;
}
@@ -828,8 +806,7 @@ public class ThreadDatabase extends Database {
}
private boolean deleteThreadOnEmpty(long threadId) {
- Recipient threadRecipient = getRecipientForThreadId(threadId);
- return threadRecipient != null && !threadRecipient.isOpenGroupRecipient();
+ return false;
}
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
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 3a3347a20..17fed47aa 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
@@ -89,11 +89,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV41 = 62;
private static final int lokiV42 = 63;
private static final int lokiV43 = 64;
-
private static final int lokiV44 = 65;
+ private static final int lokiV45 = 66;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
- private static final int DATABASE_VERSION = lokiV44;
+ private static final int DATABASE_VERSION = lokiV45;
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";
@@ -360,6 +360,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
db.execSQL(RecipientDatabase.getAddWrapperHash());
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
+
+ db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
+ db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
}
@Override
@@ -610,6 +613,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs);
}
+ if (oldVersion < lokiV45) {
+ db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
+ db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
+ }
+
db.setTransactionSuccessful();
} finally {
db.endTransaction();
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 936e4f287..6ef9afdb2 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 androidx.annotation.StringRes
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.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
+import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -22,8 +28,23 @@ abstract class AppModule {
}
+@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 {
fun getPrefs(): TextSecurePreferences
+}
+
+fun interface Toaster {
+ fun toast(@StringRes stringRes: Int, toastLength: Int, vararg parameters: Any)
}
\ No newline at end of file
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 da15c2f6b..8e0c73577 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 d664ffedb..e457dac0b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
@@ -2,17 +2,25 @@ package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.os.Trace
+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 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.Hex
+import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager
@@ -61,7 +69,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()
@@ -72,7 +80,11 @@ class ConfigFactory(
override val user: UserProfile?
get() = synchronizedWithLog(userLock) {
- if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
+ if (!ConfigBase.isNewConfigEnabled(
+ isConfigForcedOn,
+ SnodeAPI.nowWithOffset
+ )
+ ) return null
if (_userConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userDump = configDatabase.retrieveConfigAndHashes(
@@ -92,7 +104,11 @@ class ConfigFactory(
override val contacts: Contacts?
get() = synchronizedWithLog(contactsLock) {
- if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
+ if (!ConfigBase.isNewConfigEnabled(
+ isConfigForcedOn,
+ SnodeAPI.nowWithOffset
+ )
+ ) return null
if (_contacts == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val contactsDump = configDatabase.retrieveConfigAndHashes(
@@ -112,7 +128,11 @@ class ConfigFactory(
override val convoVolatile: ConversationVolatileConfig?
get() = synchronizedWithLog(convoVolatileLock) {
- if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
+ if (!ConfigBase.isNewConfigEnabled(
+ isConfigForcedOn,
+ SnodeAPI.nowWithOffset
+ )
+ ) return null
if (_convoVolatileConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val convoDump = configDatabase.retrieveConfigAndHashes(
@@ -133,7 +153,11 @@ class ConfigFactory(
override val userGroups: UserGroupsConfig?
get() = synchronizedWithLog(userGroupsLock) {
- if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
+ if (!ConfigBase.isNewConfigEnabled(
+ isConfigForcedOn,
+ SnodeAPI.nowWithOffset
+ )
+ ) return null
if (_userGroups == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userGroupsDump = configDatabase.retrieveConfigAndHashes(
@@ -151,6 +175,86 @@ class ConfigFactory(
_userGroups
}
+ private fun getGroupAuthInfo(groupSessionId: SessionId) = userGroups?.getClosedGroup(groupSessionId.hexString())?.let {
+ it.adminKey to it.authData
+ }
+
+ override fun getGroupInfoConfig(groupSessionId: SessionId): GroupInfoConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) ->
+ // get any potential initial dumps
+ val dump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.INFO_VARIANT,
+ groupSessionId.hexString()
+ ) ?: byteArrayOf()
+
+ GroupInfoConfig.newInstance(Hex.fromStringCondensed(groupSessionId.publicKey), sk, dump)
+ }
+
+ override fun getGroupKeysConfig(groupSessionId: SessionId,
+ info: GroupInfoConfig?,
+ members: GroupMembersConfig?,
+ free: Boolean): GroupKeysConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) ->
+ // 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,
+ Hex.fromStringCondensed(groupSessionId.publicKey),
+ sk,
+ dump,
+ usedInfo,
+ usedMembers
+ )
+ if (free) {
+ info?.free()
+ members?.free()
+ }
+ if (usedInfo !== info) usedInfo.free()
+ if (usedMembers !== members) usedMembers.free()
+ keys
+ }
+
+ override fun getGroupMemberConfig(groupSessionId: SessionId): GroupMembersConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, auth) ->
+ // Get initial dump if we have one
+ val dump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.MEMBER_VARIANT,
+ groupSessionId.hexString()
+ ) ?: byteArrayOf()
+
+ GroupMembersConfig.newInstance(
+ Hex.fromStringCondensed(groupSessionId.publicKey),
+ sk,
+ dump
+ )
+ }
+
+ override fun constructGroupKeysConfig(
+ groupSessionId: SessionId,
+ info: GroupInfoConfig,
+ members: GroupMembersConfig
+ ): GroupKeysConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) ->
+ val (userSk, _) = maybeGetUserInfo() ?: return null
+ GroupKeysConfig.newInstance(
+ userSk,
+ Hex.fromStringCondensed(groupSessionId.publicKey),
+ sk,
+ info = info,
+ members = members
+ )
+ }
+
override fun getUserConfigs(): List =
listOfNotNull(user, contacts, convoVolatile, userGroups)
@@ -158,13 +262,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) {
@@ -181,10 +295,30 @@ 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: SessionId, 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
+ )
+ }
+
+ override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) {
try {
listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject)
@@ -194,6 +328,8 @@ class ConfigFactory(
is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
+ is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, SessionId.from(forPublicKey!!), timestamp)
+ is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, SessionId.from(forPublicKey!!), timestamp)
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
}
} catch (e: Exception) {
@@ -214,23 +350,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
@@ -240,12 +378,38 @@ class ConfigFactory(
return false
}
- override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
+ override fun canPerformChange(
+ variant: String,
+ publicKey: String,
+ changeTimestampMs: Long
+ ): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
- val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
+ 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
+ configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp)
+ }
+
+ override fun removeGroup(closedGroupId: SessionId) {
+ val groups = userGroups ?: return
+ groups.eraseClosedGroup(closedGroupId.hexString())
+ 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 000000000..5a1dee052
--- /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 f2c046e0a..d37aa5ce2 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.helpers.SQLCipherOpenHelper
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 524100190..2ad57c494 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
@@ -131,8 +131,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,
+ toaster: Toaster): Storage {
+ val storage = Storage(context, openHelper, configFactory, pollerFactory, toaster)
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 000000000..a0059cfc8
--- /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.SessionId
+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: SessionId): ClosedGroupPoller? {
+ // Check if the group is currently in our config, don't start if it isn't
+ configFactory.userGroups?.getClosedGroup(sessionId.hexString()) ?: return null
+
+ return pollers.getOrPut(sessionId) {
+ ClosedGroupPoller(scope + SupervisorJob(), executor, sessionId, configFactory)
+ }
+ }
+
+ fun startAll() {
+ configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited)?.forEach {
+ pollerFor(it.groupSessionId)?.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.groupSessionId } }
+ 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 cd4b07133..7681536af 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 f8e64dd38..e50461c9e 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
@@ -26,7 +26,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) {
@@ -34,16 +34,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(groupRecipientSettings: Recipient.RecipientSettings, 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 ecd40938a..36f4a9585 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
@@ -1,47 +1,44 @@
package org.thoughtcrime.securesms.groups
-import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.os.Parcelable
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.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.RecyclerView
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.ramcosta.composedestinations.DestinationsNavHost
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.dependency
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultRecipient
import dagger.hilt.android.AndroidEntryPoint
-import network.loki.messenger.R
+import kotlinx.parcelize.Parcelize
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.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
-import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
-import org.thoughtcrime.securesms.mms.GlideApp
-import org.thoughtcrime.securesms.util.fadeIn
-import org.thoughtcrime.securesms.util.fadeOut
-import javax.inject.Inject
+import org.thoughtcrime.securesms.groups.compose.CreateGroup
+import org.thoughtcrime.securesms.groups.compose.CreateGroupNavGraph
+import org.thoughtcrime.securesms.groups.compose.SelectContacts
+import org.thoughtcrime.securesms.groups.compose.StateUpdate
+import org.thoughtcrime.securesms.groups.compose.ViewState
+import org.thoughtcrime.securesms.groups.destinations.SelectContactsScreenDestination
+import org.thoughtcrime.securesms.ui.AppTheme
@AndroidEntryPoint
class CreateGroupFragment : Fragment() {
- @Inject
- lateinit var device: Device
-
private lateinit var binding: FragmentCreateGroupBinding
- private val viewModel: CreateGroupViewModel by viewModels()
lateinit var delegate: NewConversationDelegate
@@ -49,76 +46,91 @@ class CreateGroupFragment : Fragment() {
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- binding = FragmentCreateGroupBinding.inflate(inflater)
- return binding.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val adapter = SelectContactsAdapter(requireContext(), GlideApp.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() }
+ return ComposeView(requireContext()).apply {
+ val getDelegate = { delegate }
+ setContent {
+ AppTheme {
+ DestinationsNavHost(
+ navGraph = NavGraphs.createGroup,
+ dependenciesContainerBuilder = {
+ dependency(getDelegate)
+ })
+ }
}
}
- 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.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show()
- }
- if (name.length >= 30) {
- return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show()
- }
- val selectedMembers = adapter.selectedMembers
- if (selectedMembers.isEmpty()) {
- return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_not_enough_group_members_error, 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.activity_create_closed_group_too_many_group_members_error, 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)
+}
+
+@Parcelize
+data class ContactList(val contacts: Set) : Parcelable
+
+@CreateGroupNavGraph(start = true)
+@Composable
+@Destination
+fun CreateGroupScreen(
+ navigator: DestinationsNavigator,
+ resultSelectContact: ResultRecipient,
+ viewModel: CreateGroupViewModel = hiltViewModel(),
+ getDelegate: () -> NewConversationDelegate
+) {
+ val viewState by viewModel.viewState.observeAsState(ViewState.DEFAULT)
+
+ resultSelectContact.onNavResult { navResult ->
+ when (navResult) {
+ is NavResult.Value -> {
+ viewModel.updateState(StateUpdate.AddContacts(navResult.value.contacts))
+ }
+
+ is NavResult.Canceled -> { /* do nothing */
+ }
+ }
}
+ val context = LocalContext.current
+
+ viewState.createdGroup?.let { group ->
+ SideEffect {
+ getDelegate().onDialogClosePressed()
+ val intent = Intent(context, ConversationActivityV2::class.java).apply {
+ putExtra(ConversationActivityV2.ADDRESS, group.address)
+ }
+ context.startActivity(intent)
+ }
+ }
+
+ CreateGroup(
+ viewState,
+ viewModel::updateState,
+ onClose = {
+ getDelegate().onDialogClosePressed()
+ },
+ onSelectContact = { navigator.navigate(SelectContactsScreenDestination) },
+ onBack = {
+ getDelegate().onDialogBackPressed()
+ }
+ )
+}
+
+@CreateGroupNavGraph
+@Composable
+@Destination
+fun SelectContactsScreen(
+ resultNavigator: ResultBackNavigator,
+ viewModel: CreateGroupViewModel = hiltViewModel(),
+ getDelegate: () -> NewConversationDelegate
+) {
+
+ val viewState by viewModel.viewState.observeAsState(ViewState.DEFAULT)
+ val currentMembers = viewState.members
+ val contacts by viewModel.contacts.observeAsState(initial = emptySet())
+
+ SelectContacts(
+ contacts - currentMembers,
+ onBack = { resultNavigator.navigateBack() },
+ onClose = { getDelegate().onDialogClosePressed() },
+ onContactsSelected = {
+ resultNavigator.navigateBack(ContactList(it))
+ }
+ )
}
\ 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 b3dbb4938..b88f08348 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt
@@ -3,44 +3,80 @@ package org.thoughtcrime.securesms.groups
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
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 network.loki.messenger.R
+import org.thoughtcrime.securesms.database.Storage
+import org.thoughtcrime.securesms.groups.compose.StateUpdate
+import org.thoughtcrime.securesms.groups.compose.ViewState
import javax.inject.Inject
@HiltViewModel
class CreateGroupViewModel @Inject constructor(
- private val threadDb: ThreadDatabase,
- private val textSecurePreferences: TextSecurePreferences
+ private val storage: Storage,
) : ViewModel() {
- private val _recipients = MutableLiveData>()
- val recipients: LiveData> = _recipients
+ private inline fun MutableLiveData.update(body: T.() -> T) {
+ this.postValue(body(this.value!!))
+ }
- init {
- 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() }
- }
- }
+ private val _viewState = MutableLiveData(ViewState.DEFAULT.copy())
+
+ val viewState: LiveData = _viewState
+
+ fun updateState(stateUpdate: StateUpdate) {
+ when (stateUpdate) {
+ is StateUpdate.AddContacts -> _viewState.update { copy(members = members + stateUpdate.value) }
+ is StateUpdate.Description -> _viewState.update { copy(description = stateUpdate.value) }
+ is StateUpdate.Name -> _viewState.update { copy(name = stateUpdate.value) }
+ is StateUpdate.RemoveContact -> _viewState.update { copy(members = members - stateUpdate.value) }
+ StateUpdate.Create -> { viewModelScope.launch { tryCreateGroup() } }
}
}
- fun filter(query: String): List {
- return _recipients.value?.filter {
- it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true
- } ?: emptyList()
+ val contacts
+ get() = liveData { emit(storage.getAllContacts()) }
+
+ fun tryCreateGroup() {
+
+ val currentState = _viewState.value!!
+
+ _viewState.postValue(currentState.copy(isLoading = true, error = null))
+
+ val name = currentState.name
+ val description = currentState.description
+ val members = currentState.members.toMutableSet()
+
+ // do some validation
+ // need a name
+ if (name.isEmpty()) {
+ return _viewState.postValue(
+ currentState.copy(isLoading = false, error = R.string.error)
+ )
+ }
+
+ if (members.size <= 1) {
+ _viewState.postValue(
+ currentState.copy(
+ isLoading = false,
+ error = R.string.activity_create_closed_group_not_enough_group_members_error
+ )
+ )
+ }
+
+ // make a group
+ val newGroup = storage.createNewGroup(name, description, members)
+ if (!newGroup.isPresent) {
+ // show a generic couldn't create or something?
+ return _viewState.postValue(currentState.copy(isLoading = false, error = null))
+ } else {
+ return _viewState.postValue(currentState.copy(
+ isLoading = false,
+ error = null,
+ createdGroup = newGroup.get())
+ )
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt
index 9fee8adaf..9f4e00a7b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt
@@ -1,342 +1,50 @@
package org.thoughtcrime.securesms.groups
-import android.content.Context
-import android.content.Intent
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.view.inputmethod.EditorInfo
-import android.view.inputmethod.InputMethodManager
-import android.widget.EditText
-import android.widget.LinearLayout
-import android.widget.TextView
-import android.widget.Toast
-import androidx.loader.app.LoaderManager
-import androidx.loader.content.Loader
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
+import androidx.activity.compose.setContent
+import com.ramcosta.composedestinations.DestinationsNavHost
+import com.ramcosta.composedestinations.navigation.dependency
import dagger.hilt.android.AndroidEntryPoint
-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
-import org.session.libsession.utilities.GroupUtil
-import org.session.libsession.utilities.TextSecurePreferences
-import org.session.libsession.utilities.ThemeUtil
-import org.session.libsession.utilities.recipients.Recipient
-import org.session.libsignal.utilities.Log
-import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
-import org.thoughtcrime.securesms.contacts.SelectContactsActivity
-import org.thoughtcrime.securesms.database.Storage
-import org.thoughtcrime.securesms.dependencies.ConfigFactory
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
-import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
-import org.thoughtcrime.securesms.mms.GlideApp
-import org.thoughtcrime.securesms.util.fadeIn
-import org.thoughtcrime.securesms.util.fadeOut
-import java.io.IOException
+import org.thoughtcrime.securesms.groups.compose.EditGroupInviteViewModel
+import org.thoughtcrime.securesms.groups.compose.EditGroupViewModel
+import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupScreenDestination
+import org.thoughtcrime.securesms.ui.AppTheme
import javax.inject.Inject
@AndroidEntryPoint
-class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
-
- @Inject
- lateinit var groupConfigFactory: ConfigFactory
- @Inject
- lateinit var storage: Storage
-
- private val originalMembers = HashSet()
- private val zombies = HashSet()
- private val members = HashSet()
- private val allMembers: Set
- get() {
- return members + zombies
- }
- private var hasNameChanged = false
- private var isSelfAdmin = false
- private var isLoading = false
- set(newValue) { field = newValue; invalidateOptionsMenu() }
-
- private lateinit var groupID: String
- private lateinit var originalName: String
- private lateinit var name: String
-
- private var isEditingName = false
- set(value) {
- if (field == value) return
- field = value
- handleIsEditingNameChanged()
- }
-
- private val memberListAdapter by lazy {
- if (isSelfAdmin)
- EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick)
- else
- EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin)
- }
-
- private lateinit var mainContentContainer: LinearLayout
- private lateinit var cntGroupNameEdit: LinearLayout
- private lateinit var cntGroupNameDisplay: LinearLayout
- private lateinit var edtGroupName: EditText
- private lateinit var emptyStateContainer: LinearLayout
- private lateinit var lblGroupNameDisplay: TextView
- private lateinit var loaderContainer: View
+class EditClosedGroupActivity: PassphraseRequiredActionBarActivity() {
companion object {
- @JvmStatic val groupIDKey = "groupIDKey"
- private val loaderID = 0
- val addUsersRequestCode = 124
- val legacyGroupSizeLimit = 10
+ const val groupIDKey = "EditClosedGroupActivity_groupID"
}
- // region Lifecycle
- override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
- super.onCreate(savedInstanceState, isReady)
- setContentView(R.layout.activity_edit_closed_group)
+ @Inject lateinit var editFactory: EditGroupViewModel.Factory
+ @Inject lateinit var inviteFactory: EditGroupInviteViewModel.Factory
- supportActionBar!!.setHomeAsUpIndicator(
- ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable))
-
- groupID = intent.getStringExtra(groupIDKey)!!
- val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get()
- originalName = groupInfo.title
- isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) }
-
- name = originalName
-
- mainContentContainer = findViewById(R.id.mainContentContainer)
- cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit)
- cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay)
- edtGroupName = findViewById(R.id.edtGroupName)
- emptyStateContainer = findViewById(R.id.emptyStateContainer)
- lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay)
- loaderContainer = findViewById(R.id.loaderContainer)
-
- findViewById(R.id.addMembersClosedGroupButton).setOnClickListener {
- onAddMembersClick()
- }
-
- findViewById(R.id.rvUserList).apply {
- adapter = memberListAdapter
- layoutManager = LinearLayoutManager(this@EditClosedGroupActivity)
- }
-
- lblGroupNameDisplay.text = originalName
- cntGroupNameDisplay.setOnClickListener { isEditingName = true }
- findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false }
- findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() }
- edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE)
- edtGroupName.setOnEditorActionListener { _, actionId, _ ->
- when (actionId) {
- EditorInfo.IME_ACTION_DONE -> {
- saveName()
- return@setOnEditorActionListener true
- }
- else -> return@setOnEditorActionListener false
- }
- }
-
- LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks {
-
- override fun onCreateLoader(id: Int, bundle: Bundle?): Loader {
- return EditClosedGroupLoader(this@EditClosedGroupActivity, 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)
-
- members.clear()
- members.addAll(groupMembers.members.toHashSet())
- zombies.clear()
- zombies.addAll(groupMembers.zombieMembers.toHashSet())
- originalMembers.clear()
- originalMembers.addAll(members + zombies)
- updateMembers()
- }
-
- override fun onLoaderReset(loader: Loader) {
- updateMembers()
- }
- })
+ private fun onFinish() {
+ finish()
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_edit_closed_group, menu)
- return allMembers.isNotEmpty() && !isLoading
- }
- // endregion
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ setContent {
- // region Updating
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- super.onActivityResult(requestCode, resultCode, data)
- when (requestCode) {
- addUsersRequestCode -> {
- if (resultCode != RESULT_OK) return
- if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return
-
- val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet()
- members.addAll(selectedContacts)
- updateMembers()
- }
- }
- }
-
- private fun handleIsEditingNameChanged() {
- cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE
- cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE
- val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- if (isEditingName) {
- edtGroupName.setText(name)
- edtGroupName.selectAll()
- edtGroupName.requestFocus()
- inputMethodManager.showSoftInput(edtGroupName, 0)
- } else {
- inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0)
- }
- }
-
- private fun updateMembers() {
- memberListAdapter.setMembers(allMembers)
- memberListAdapter.setZombieMembers(zombies)
-
- mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE
- emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE
-
- invalidateOptionsMenu()
- }
- // endregion
-
- // region Interaction
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- R.id.action_apply -> if (!isLoading) { commitChanges() }
- }
- return super.onOptionsItemSelected(item)
- }
-
- private fun onMemberClick(member: String) {
- val bottomSheet = ClosedGroupEditingOptionsBottomSheet()
- bottomSheet.onRemoveTapped = {
- if (zombies.contains(member)) zombies.remove(member)
- else members.remove(member)
- updateMembers()
- bottomSheet.dismiss()
- }
- bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet")
- }
-
- private fun onAddMembersClick() {
- val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java)
- intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
- intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
- startActivityForResult(intent, addUsersRequestCode)
- }
-
- private fun saveName() {
- val name = edtGroupName.text.toString().trim()
- if (name.isEmpty()) {
- return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show()
- }
- if (name.length >= 64) {
- return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show()
- }
- this.name = name
- lblGroupNameDisplay.text = name
- hasNameChanged = true
- isEditingName = false
- }
-
- private fun commitChanges() {
- val hasMemberListChanges = (allMembers != originalMembers)
-
- if (!hasNameChanged && !hasMemberListChanges) {
- return finish()
- }
-
- val name = if (hasNameChanged) this.name else originalName
-
- val members = this.allMembers.map {
- Recipient.from(this, Address.fromSerialized(it), false)
- }.toSet()
- val originalMembers = this.originalMembers.map {
- Recipient.from(this, Address.fromSerialized(it), false)
- }.toSet()
-
- var isClosedGroup: Boolean
- var groupPublicKey: String?
- try {
- groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString()
- isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey)
- } catch (e: IOException) {
- groupPublicKey = null
- isClosedGroup = false
- }
-
- if (members.isEmpty()) {
- return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show()
- }
-
- val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit
- if (members.size >= maxGroupMembers) {
- return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show()
- }
-
- val userPublicKey = TextSecurePreferences.getLocalNumber(this)!!
- val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false)
-
- if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) {
- val message = "Can't leave while adding or removing other members."
- return Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show()
- }
-
- 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 {
- if (hasNameChanged) {
- MessageSender.explicitNameChange(groupPublicKey!!, name)
+ AppTheme {
+ DestinationsNavHost(
+ navGraph = NavGraphs.editGroup,
+ dependenciesContainerBuilder = {
+ dependency(NavGraphs.editGroup) {
+ editFactory.create(intent.getStringExtra(groupIDKey)!!, contentResolver)
+ }
+ dependency(NavGraphs.editGroup) {
+ inviteFactory.create(intent.getStringExtra(groupIDKey)!!)
+ }
+ dependency(EditClosedGroupScreenDestination) {
+ ::onFinish
+ }
}
- members.filterNot { it in originalMembers }.let { adds ->
- if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() })
- }
- originalMembers.filterNot { it in members }.let { removes ->
- if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
- }
- }
- }
- promise.successUi {
- loaderContainer.fadeOut()
- isLoading = false
- updateGroupConfig()
- finish()
- }.failUi { exception ->
- val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
- Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show()
- loaderContainer.fadeOut()
- isLoading = false
+ )
}
}
}
-
- private fun updateGroupConfig() {
- val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
- ?: return Log.w("Loki", "No recipient settings when trying to update group config")
- val latestGroup = storage.getGroup(groupID)
- ?: return Log.w("Loki", "No group record when trying to update group config")
- groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
- }
-
- class GroupMembers(val members: List, val zombieMembers: List)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt
index b1e0b5e1d..2acdfcd78 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.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 EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) {
- override fun loadInBackground(): EditClosedGroupActivity.GroupMembers {
+ override fun loadInBackground(): EditLegacyClosedGroupActivity.GroupMembers {
val groupDatabase = DatabaseComponent.get(context).groupDatabase()
val members = groupDatabase.getGroupMembers(groupID, true)
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
- return EditClosedGroupActivity.GroupMembers(
+ return EditLegacyClosedGroupActivity.GroupMembers(
members.map {
it.address.toString()
},
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupActivity.kt
new file mode 100644
index 000000000..fb572f0f0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupActivity.kt
@@ -0,0 +1,342 @@
+package org.thoughtcrime.securesms.groups
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.loader.app.LoaderManager
+import androidx.loader.content.Loader
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import dagger.hilt.android.AndroidEntryPoint
+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
+import org.session.libsession.utilities.GroupUtil
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.ThemeUtil
+import org.session.libsession.utilities.recipients.Recipient
+import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.toHexString
+import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.contacts.SelectContactsActivity
+import org.thoughtcrime.securesms.database.Storage
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
+import org.thoughtcrime.securesms.dependencies.DatabaseComponent
+import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
+import org.thoughtcrime.securesms.mms.GlideApp
+import org.thoughtcrime.securesms.util.fadeIn
+import org.thoughtcrime.securesms.util.fadeOut
+import java.io.IOException
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class EditLegacyClosedGroupActivity : PassphraseRequiredActionBarActivity() {
+
+ @Inject
+ lateinit var groupConfigFactory: ConfigFactory
+ @Inject
+ lateinit var storage: Storage
+
+ private val originalMembers = HashSet()
+ private val zombies = HashSet()
+ private val members = HashSet()
+ private val allMembers: Set
+ get() {
+ return members + zombies
+ }
+ private var hasNameChanged = false
+ private var isSelfAdmin = false
+ private var isLoading = false
+ set(newValue) { field = newValue; invalidateOptionsMenu() }
+
+ private lateinit var groupID: String
+ private lateinit var originalName: String
+ private lateinit var name: String
+
+ private var isEditingName = false
+ set(value) {
+ if (field == value) return
+ field = value
+ handleIsEditingNameChanged()
+ }
+
+ private val memberListAdapter by lazy {
+ if (isSelfAdmin)
+ EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick)
+ else
+ EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin)
+ }
+
+ private lateinit var mainContentContainer: LinearLayout
+ private lateinit var cntGroupNameEdit: LinearLayout
+ private lateinit var cntGroupNameDisplay: LinearLayout
+ private lateinit var edtGroupName: EditText
+ private lateinit var emptyStateContainer: LinearLayout
+ private lateinit var lblGroupNameDisplay: TextView
+ private lateinit var loaderContainer: View
+
+ companion object {
+ @JvmStatic val groupIDKey = "groupIDKey"
+ private val loaderID = 0
+ val addUsersRequestCode = 124
+ val legacyGroupSizeLimit = 10
+ }
+
+ // region Lifecycle
+ override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
+ super.onCreate(savedInstanceState, isReady)
+ setContentView(R.layout.activity_edit_closed_group)
+
+ supportActionBar!!.setHomeAsUpIndicator(
+ ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable))
+
+ groupID = intent.getStringExtra(groupIDKey)!!
+ val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get()
+ originalName = groupInfo.title
+ isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) }
+
+ name = originalName
+
+ mainContentContainer = findViewById(R.id.mainContentContainer)
+ cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit)
+ cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay)
+ edtGroupName = findViewById(R.id.edtGroupName)
+ emptyStateContainer = findViewById(R.id.emptyStateContainer)
+ lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay)
+ loaderContainer = findViewById(R.id.loaderContainer)
+
+ findViewById(R.id.addMembersClosedGroupButton).setOnClickListener {
+ onAddMembersClick()
+ }
+
+ findViewById(R.id.rvUserList).apply {
+ adapter = memberListAdapter
+ layoutManager = LinearLayoutManager(this@EditLegacyClosedGroupActivity)
+ }
+
+ lblGroupNameDisplay.text = originalName
+ cntGroupNameDisplay.setOnClickListener { isEditingName = true }
+ findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false }
+ findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() }
+ edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE)
+ edtGroupName.setOnEditorActionListener { _, actionId, _ ->
+ when (actionId) {
+ EditorInfo.IME_ACTION_DONE -> {
+ saveName()
+ return@setOnEditorActionListener true
+ }
+ else -> return@setOnEditorActionListener false
+ }
+ }
+
+ LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks {
+
+ override fun onCreateLoader(id: Int, bundle: Bundle?): Loader {
+ return EditClosedGroupLoader(this@EditLegacyClosedGroupActivity, 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@EditLegacyClosedGroupActivity).destroyLoader(loaderID)
+
+ members.clear()
+ members.addAll(groupMembers.members.toHashSet())
+ zombies.clear()
+ zombies.addAll(groupMembers.zombieMembers.toHashSet())
+ originalMembers.clear()
+ originalMembers.addAll(members + zombies)
+ updateMembers()
+ }
+
+ override fun onLoaderReset(loader: Loader) {
+ updateMembers()
+ }
+ })
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_edit_closed_group, menu)
+ return allMembers.isNotEmpty() && !isLoading
+ }
+ // endregion
+
+ // region Updating
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ when (requestCode) {
+ addUsersRequestCode -> {
+ if (resultCode != RESULT_OK) return
+ if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return
+
+ val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet()
+ members.addAll(selectedContacts)
+ updateMembers()
+ }
+ }
+ }
+
+ private fun handleIsEditingNameChanged() {
+ cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE
+ cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE
+ val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ if (isEditingName) {
+ edtGroupName.setText(name)
+ edtGroupName.selectAll()
+ edtGroupName.requestFocus()
+ inputMethodManager.showSoftInput(edtGroupName, 0)
+ } else {
+ inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0)
+ }
+ }
+
+ private fun updateMembers() {
+ memberListAdapter.setMembers(allMembers)
+ memberListAdapter.setZombieMembers(zombies)
+
+ mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE
+ emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE
+
+ invalidateOptionsMenu()
+ }
+ // endregion
+
+ // region Interaction
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.action_apply -> if (!isLoading) { commitChanges() }
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ private fun onMemberClick(member: String) {
+ val bottomSheet = ClosedGroupEditingOptionsBottomSheet()
+ bottomSheet.onRemoveTapped = {
+ if (zombies.contains(member)) zombies.remove(member)
+ else members.remove(member)
+ updateMembers()
+ bottomSheet.dismiss()
+ }
+ bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet")
+ }
+
+ private fun onAddMembersClick() {
+ val intent = Intent(this@EditLegacyClosedGroupActivity, SelectContactsActivity::class.java)
+ intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
+ intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
+ startActivityForResult(intent, addUsersRequestCode)
+ }
+
+ private fun saveName() {
+ val name = edtGroupName.text.toString().trim()
+ if (name.isEmpty()) {
+ return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show()
+ }
+ if (name.length >= 64) {
+ return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show()
+ }
+ this.name = name
+ lblGroupNameDisplay.text = name
+ hasNameChanged = true
+ isEditingName = false
+ }
+
+ private fun commitChanges() {
+ val hasMemberListChanges = (allMembers != originalMembers)
+
+ if (!hasNameChanged && !hasMemberListChanges) {
+ return finish()
+ }
+
+ val name = if (hasNameChanged) this.name else originalName
+
+ val members = this.allMembers.map {
+ Recipient.from(this, Address.fromSerialized(it), false)
+ }.toSet()
+ val originalMembers = this.originalMembers.map {
+ Recipient.from(this, Address.fromSerialized(it), false)
+ }.toSet()
+
+ var isClosedGroup: Boolean
+ var groupPublicKey: String?
+ try {
+ groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString()
+ isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey)
+ } catch (e: IOException) {
+ groupPublicKey = null
+ isClosedGroup = false
+ }
+
+ if (members.isEmpty()) {
+ return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show()
+ }
+
+ val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit
+ if (members.size >= maxGroupMembers) {
+ return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show()
+ }
+
+ val userPublicKey = TextSecurePreferences.getLocalNumber(this)!!
+ val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false)
+
+ if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) {
+ val message = "Can't leave while adding or removing other members."
+ return Toast.makeText(this@EditLegacyClosedGroupActivity, message, Toast.LENGTH_LONG).show()
+ }
+
+ 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 {
+ if (hasNameChanged) {
+ MessageSender.explicitNameChange(groupPublicKey!!, name)
+ }
+ members.filterNot { it in originalMembers }.let { adds ->
+ if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() })
+ }
+ originalMembers.filterNot { it in members }.let { removes ->
+ if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
+ }
+ }
+ }
+ promise.successUi {
+ loaderContainer.fadeOut()
+ isLoading = false
+ updateGroupConfig()
+ finish()
+ }.failUi { exception ->
+ val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
+ Toast.makeText(this@EditLegacyClosedGroupActivity, message, Toast.LENGTH_LONG).show()
+ loaderContainer.fadeOut()
+ isLoading = false
+ }
+ }
+ }
+
+ private fun updateGroupConfig() {
+ val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
+ ?: return Log.w("Loki", "No recipient settings when trying to update group config")
+ val latestGroup = storage.getGroup(groupID)
+ ?: return Log.w("Loki", "No group record when trying to update group config")
+ groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
+ }
+
+ class GroupMembers(val members: List, val zombieMembers: List)
+}
\ No newline at end of file
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 000000000..0b18b4e7a
--- /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/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt
new file mode 100644
index 000000000..8da92c685
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt
@@ -0,0 +1,190 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+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.shape.CircleShape
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.RadioButton
+import androidx.compose.material.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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+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.home.search.getSearchName
+import org.thoughtcrime.securesms.ui.Avatar
+import org.thoughtcrime.securesms.ui.LocalPreviewMode
+import org.thoughtcrime.securesms.ui.PreviewTheme
+import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
+
+
+@Composable
+fun EmptyPlaceholder(modifier: Modifier = Modifier) {
+ Column(modifier) {
+ Text(
+ text = stringResource(id = R.string.activity_create_closed_group_empty_placeholer),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ )
+ }
+}
+
+fun LazyListScope.multiSelectMemberList(
+ contacts: List,
+ modifier: Modifier = Modifier,
+ selectedContacts: Set = emptySet(),
+ onListUpdated: (Set)->Unit = {},
+) {
+ items(contacts) { contact ->
+ val isSelected = selectedContacts.contains(contact)
+
+ val update = {
+ val newList =
+ if (isSelected) selectedContacts - contact
+ else selectedContacts + contact
+ onListUpdated(newList)
+ }
+
+ Row(modifier = modifier.fillMaxWidth()
+ .clickable(onClick = update)
+ .padding(vertical = 8.dp, horizontal = 24.dp),
+ verticalAlignment = CenterVertically
+ ) {
+ ContactPhoto(
+ contact.sessionID,
+ modifier = Modifier
+ .size(48.dp)
+ )
+ MemberName(name = contact.getSearchName(), modifier = Modifier.padding(16.dp))
+ RadioButton(selected = isSelected, onClick = update)
+ }
+ }
+}
+
+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)
+)
+
+fun LazyListScope.deleteMemberList(
+ contacts: List,
+ modifier: Modifier = Modifier,
+ onDelete: (Contact) -> Unit,
+) {
+ item {
+ Text(
+ text = stringResource(id = R.string.conversation_settings_group_members),
+ style = MaterialTheme.typography.subtitle2,
+ modifier = modifier
+ .padding(vertical = 8.dp)
+ )
+ }
+ if (contacts.isEmpty()) {
+ item {
+ EmptyPlaceholder(modifier.fillMaxWidth())
+ }
+ } else {
+ items(contacts) { contact ->
+ Row(modifier.fillMaxWidth()) {
+ ContactPhoto(
+ contact.sessionID,
+ modifier = Modifier
+ .size(48.dp)
+ .align(CenterVertically)
+ )
+ MemberName(name = contact.getSearchName(), modifier = Modifier.padding(16.dp))
+ Image(
+ painterResource(id = R.drawable.ic_baseline_close_24),
+ null,
+ modifier = Modifier
+ .size(32.dp)
+ .align(CenterVertically)
+ .clickable {
+ onDelete(contact)
+ },
+ )
+ }
+ }
+ }
+}
+
+
+@Composable
+fun RowScope.ContactPhoto(sessionId: String, modifier: Modifier = Modifier) {
+ return if (LocalPreviewMode.current) {
+ Image(
+ painterResource(id = R.drawable.ic_profile_default),
+ colorFilter = ColorFilter.tint(MaterialTheme.colors.onPrimary),
+ contentScale = ContentScale.Inside,
+ contentDescription = null,
+ modifier = modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .border(1.dp, MaterialTheme.colors.onPrimary, 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(
+ @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
+) {
+ val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
+ val previewMembers = setOf(
+ Contact(random).apply {
+ name = "Person"
+ }
+ )
+ PreviewTheme(themeResId = themeResId) {
+ LazyColumn {
+ multiSelectMemberList(
+ contacts = previewMembers.toList(),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroup.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroup.kt
new file mode 100644
index 000000000..52c86ee4e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroup.kt
@@ -0,0 +1,235 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedButton
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+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.recipients.Recipient
+import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
+import org.thoughtcrime.securesms.ui.CloseIcon
+import org.thoughtcrime.securesms.ui.Divider
+import org.thoughtcrime.securesms.ui.EditableAvatar
+import org.thoughtcrime.securesms.ui.NavigationBar
+import org.thoughtcrime.securesms.ui.PreviewTheme
+
+
+@Composable
+fun CreateGroup(
+ viewState: ViewState,
+ updateState: (StateUpdate) -> Unit,
+ onSelectContact: () -> Unit,
+ onBack: () -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+
+ val lazyState = rememberLazyListState()
+
+ Box {
+ Column(
+ modifier
+ .fillMaxWidth()) {
+ LazyColumn(state = lazyState) {
+ // Top bar
+ item {
+ Column(modifier.fillMaxWidth()) {
+ NavigationBar(
+ title = stringResource(id = R.string.activity_create_group_title),
+ onBack = onBack,
+ actionElement = { CloseIcon(onClose) }
+ )
+ // Editable avatar (future chunk)
+ EditableAvatar(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 16.dp)
+ )
+ // Title
+ val nameDescription = stringResource(id = R.string.AccessibilityId_closed_group_edit_group_name)
+ OutlinedTextField(
+ value = viewState.name,
+ onValueChange = { updateState(StateUpdate.Name(it)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.CenterHorizontally)
+ .padding(vertical = 8.dp, horizontal = 24.dp)
+ .semantics {
+ contentDescription = nameDescription
+ },
+ )
+ // Description
+ val descriptionDescription = stringResource(id = R.string.AccessibilityId_closed_group_edit_group_description)
+ OutlinedTextField(
+ value = viewState.description,
+ onValueChange = { updateState(StateUpdate.Description(it)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.CenterHorizontally)
+ .padding(vertical = 8.dp, horizontal = 24.dp)
+ .semantics {
+ contentDescription = descriptionDescription
+ },
+ )
+
+ CellWithPaddingAndMargin(padding = 0.dp) {
+ Column(Modifier.fillMaxSize()) {
+ // Select Contacts
+ val padding = Modifier
+ .padding(8.dp)
+ .fillMaxWidth()
+ Row(padding.clickable {
+ onSelectContact()
+ }) {
+ Image(
+ painterResource(id = R.drawable.ic_person_white_24dp),
+ null,
+ Modifier
+ .padding(4.dp)
+ .align(Alignment.CenterVertically)
+ )
+ Text(
+ stringResource(id = R.string.activity_create_closed_group_select_contacts),
+ Modifier
+ .padding(4.dp)
+ .align(Alignment.CenterVertically)
+ )
+ }
+ Divider()
+ // Add account ID or ONS
+ Row(padding) {
+ Image(
+ painterResource(id = R.drawable.ic_baseline_add_24),
+ null,
+ Modifier
+ .padding(4.dp)
+ .align(Alignment.CenterVertically)
+ )
+ Text(
+ stringResource(id = R.string.activity_create_closed_group_add_account_or_ons),
+ Modifier
+ .padding(4.dp)
+ .align(Alignment.CenterVertically)
+ )
+ }
+ }
+ }
+ }
+ }
+ // Group list
+ deleteMemberList(contacts = viewState.members, modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp)) { deletedContact ->
+ updateState(StateUpdate.RemoveContact(deletedContact))
+ }
+ }
+ // Create button
+ val createDescription = stringResource(id = R.string.AccessibilityId_create_closed_group_create_button)
+ OutlinedButton(
+ onClick = { updateState(StateUpdate.Create) },
+ enabled = viewState.canCreate,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(16.dp)
+ .semantics {
+ contentDescription = createDescription
+ }
+ ,
+ shape = RoundedCornerShape(32.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.activity_create_group_create_button_title),
+ // TODO: colours of everything here probably needs to be redone
+ color = MaterialTheme.colors.onBackground,
+ modifier = Modifier.width(160.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ if (viewState.isLoading) {
+ Box(modifier = modifier
+ .fillMaxSize()
+ .background(Color.Gray.copy(alpha = 0.5f))) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = MaterialTheme.colors.secondary
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ClosedGroupPreview(
+) {
+ val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
+ val previewMembers = setOf(
+ Contact(random).apply {
+ name = "Person"
+ }
+ )
+ PreviewTheme(R.style.Theme_Session_DayNight_NoActionBar_Test) {
+ CreateGroup(
+ viewState = ViewState.DEFAULT.copy(
+ // override any preview parameters
+ members = previewMembers.toList()
+ ),
+ updateState = {},
+ onSelectContact = {},
+ onBack = {},
+ onClose = {},
+ )
+ }
+}
+
+data class ViewState(
+ val isLoading: Boolean,
+ @StringRes val error: Int?,
+ val name: String = "",
+ val description: String = "",
+ val members: List = emptyList(),
+ val createdGroup: Recipient? = null,
+ ) {
+
+ val canCreate
+ get() = name.isNotEmpty() && members.isNotEmpty()
+
+ companion object {
+ val DEFAULT = ViewState(false, null)
+ }
+
+}
+
+sealed class StateUpdate {
+ data object Create: StateUpdate()
+ data class Name(val value: String): StateUpdate()
+ data class Description(val value: String): StateUpdate()
+ data class RemoveContact(val value: Contact): StateUpdate()
+ data class AddContacts(val value: Set): StateUpdate()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupNavGraph.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupNavGraph.kt
new file mode 100644
index 000000000..b3dc0170c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupNavGraph.kt
@@ -0,0 +1,8 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import com.ramcosta.composedestinations.annotation.NavGraph
+
+@NavGraph
+annotation class CreateGroupNavGraph(
+ val start: Boolean = false
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt
new file mode 100644
index 000000000..417907b3d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt
@@ -0,0 +1,673 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import android.content.ContentResolver
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+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.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+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.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.cash.molecule.RecompositionMode.Immediate
+import app.cash.molecule.launchMolecule
+import com.google.android.material.color.MaterialColors
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultRecipient
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import network.loki.messenger.R
+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.thoughtcrime.securesms.groups.ContactList
+import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupInviteScreenDestination
+import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupNameScreenDestination
+import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
+import org.thoughtcrime.securesms.ui.LocalExtraColors
+import org.thoughtcrime.securesms.ui.NavigationBar
+import org.thoughtcrime.securesms.ui.PreviewTheme
+import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
+
+@EditGroupNavGraph(start = true)
+@Composable
+@Destination
+fun EditClosedGroupScreen(
+ navigator: DestinationsNavigator,
+ resultSelectContact: ResultRecipient,
+ resultEditName: ResultRecipient,
+ viewModel: EditGroupViewModel,
+ onFinish: () -> Unit
+) {
+ val group by viewModel.viewState.collectAsState()
+ val context = LocalContext.current
+ val viewState = group.viewState
+ val eventSink = group.eventSink
+
+ resultSelectContact.onNavResult { navResult ->
+ if (navResult is NavResult.Value) {
+ eventSink(EditGroupEvent.InviteContacts(context, navResult.value))
+ }
+ }
+
+ resultEditName.onNavResult { navResult ->
+ if (navResult is NavResult.Value) {
+ eventSink(EditGroupEvent.ChangeName(navResult.value))
+ }
+ }
+
+ EditGroupView(
+ onBack = {
+ onFinish()
+ },
+ onInvite = {
+ navigator.navigate(EditClosedGroupInviteScreenDestination)
+ },
+ onReinvite = { contact ->
+ eventSink(EditGroupEvent.ReInviteContact(contact))
+ },
+ onPromote = { contact ->
+ eventSink(EditGroupEvent.PromoteContact(contact))
+ },
+ onRemove = { contact ->
+ eventSink(EditGroupEvent.RemoveContact(contact))
+ },
+ onEditName = {
+ navigator.navigate(EditClosedGroupNameScreenDestination)
+ },
+ viewState = viewState
+ )
+}
+
+@EditGroupNavGraph
+@Composable
+@Destination(style = DestinationStyle.Dialog::class)
+fun EditClosedGroupNameScreen(
+ resultNavigator: ResultBackNavigator,
+ viewModel: EditGroupViewModel
+) {
+ EditClosedGroupView { name ->
+ if (name.isEmpty()) {
+ resultNavigator.navigateBack()
+ } else {
+ resultNavigator.navigateBack(name)
+ }
+ }
+}
+
+@Composable
+fun EditClosedGroupView(navigateBack: (String) -> Unit) {
+
+ var newName by remember {
+ mutableStateOf("")
+ }
+
+ var newDescription by remember {
+ mutableStateOf("")
+ }
+
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .shadow(8.dp)
+ .background(MaterialTheme.colors.surface)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(id = R.string.dialog_edit_group_information_title),
+ modifier = Modifier.padding(bottom = 8.dp),
+ style = MaterialTheme.typography.subtitle1,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = stringResource(id = R.string.dialog_edit_group_information_message),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ OutlinedTextField(
+ value = newName,
+ onValueChange = { newName = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ maxLines = 1,
+ singleLine = true,
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.dialog_edit_group_information_enter_group_name)
+ )
+ }
+ )
+ OutlinedTextField(
+ value = newDescription,
+ onValueChange = { newDescription = it },
+ modifier = Modifier
+ .fillMaxWidth(),
+ minLines = 2,
+ maxLines = 2,
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.dialog_edit_group_information_enter_group_description)
+ )
+ }
+ )
+ Row(modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(R.string.save),
+ modifier = Modifier
+ .padding(16.dp)
+ .weight(1f)
+ .clickable {
+ navigateBack(newName)
+ },
+ textAlign = TextAlign.Center,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = stringResource(R.string.cancel),
+ modifier = Modifier
+ .padding(16.dp)
+ .weight(1f)
+ .clickable {
+ navigateBack("")
+ },
+ textAlign = TextAlign.Center,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ color = LocalExtraColors.current.destructive
+ )
+ }
+ }
+ }
+}
+
+@EditGroupNavGraph
+@Composable
+@Destination
+fun EditClosedGroupInviteScreen(
+ resultNavigator: ResultBackNavigator,
+ viewModel: EditGroupInviteViewModel,
+) {
+
+ val state by viewModel.viewState.collectAsState()
+ val viewState = state.viewState
+ val currentMemberSessionIds = viewState.currentMembers.map { it.memberSessionId }
+
+ SelectContacts(
+ viewState.allContacts
+ .filterNot { it.sessionID in currentMemberSessionIds }
+ .toSet(),
+ onBack = { resultNavigator.navigateBack() },
+ onContactsSelected = {
+ resultNavigator.navigateBack(ContactList(it))
+ },
+ )
+}
+
+
+class EditGroupViewModel @AssistedInject constructor(
+ @Assisted private val groupSessionId: String,
+ @Assisted private val contentResolver: ContentResolver,
+ private val storage: StorageProtocol,
+): ViewModel() {
+
+ val viewState = viewModelScope.launchMolecule(Immediate) {
+
+ val currentUserId = rememberSaveable {
+ storage.getUserPublicKey()!!
+ }
+
+// val closedGroupRecipient by contentResolver
+// .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI)
+// .collectAsState(initial = null)
+
+ val closedGroupInfo = remember {
+ storage.getLibSessionClosedGroup(groupSessionId)!!
+ }
+
+ val closedGroup = remember(closedGroupInfo) {
+ storage.getClosedGroupDisplayInfo(groupSessionId)!!
+ }
+
+ val closedGroupMembers = remember(closedGroupInfo) {
+ storage.getMembers(groupSessionId).map { member ->
+ MemberViewModel(
+ memberName = member.name,
+ memberSessionId = member.sessionId,
+ currentUser = member.sessionId == currentUserId,
+ memberState = memberStateOf(member)
+ )
+ }
+ }
+
+ val name = closedGroup.name
+ val description = closedGroup.description
+
+ EditGroupState(
+ EditGroupViewState(
+ groupName = name,
+ groupDescription = description,
+ memberStateList = closedGroupMembers,
+ admin = closedGroup.isUserAdmin
+ )
+ ) { event ->
+ when (event) {
+ is EditGroupEvent.InviteContacts -> {
+ val sessionIds = event.contacts
+ storage.inviteClosedGroupMembers(
+ groupSessionId,
+ sessionIds.contacts.map(Contact::sessionID)
+ )
+ Toast.makeText(
+ event.context,
+ "Inviting ${event.contacts.contacts.size}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ is EditGroupEvent.ReInviteContact -> {
+ // do a buffer
+ JobQueue.shared.add(InviteContactsJob(groupSessionId, arrayOf(event.contactSessionId)))
+ }
+ is EditGroupEvent.PromoteContact -> {
+ // do a buffer
+ storage.promoteMember(groupSessionId, arrayOf(event.contactSessionId))
+ }
+ is EditGroupEvent.RemoveContact -> {
+ storage.removeMember(groupSessionId, arrayOf(event.contactSessionId))
+ }
+ is EditGroupEvent.ChangeName -> {
+ storage.setName(groupSessionId, event.newName)
+ }
+ }
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(groupSessionId: String, contentResolver: ContentResolver): EditGroupViewModel
+ }
+
+}
+
+class EditGroupInviteViewModel @AssistedInject constructor(
+ @Assisted private val groupSessionId: String,
+ private val storage: StorageProtocol
+): ViewModel() {
+
+ val viewState = viewModelScope.launchMolecule(Immediate) {
+
+ val currentUserId = rememberSaveable {
+ storage.getUserPublicKey()!!
+ }
+
+ val contacts = remember {
+ storage.getAllContacts()
+ }
+
+ val closedGroupMembers = remember {
+ storage.getMembers(groupSessionId).map { member ->
+ MemberViewModel(
+ memberName = member.name,
+ memberSessionId = member.sessionId,
+ currentUser = member.sessionId == currentUserId,
+ memberState = memberStateOf(member)
+ )
+ }
+ }
+
+ EditGroupInviteState(
+ EditGroupInviteViewState(closedGroupMembers, contacts)
+ )
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(groupSessionId: String): EditGroupInviteViewModel
+ }
+
+}
+
+@Composable
+fun EditGroupView(
+ onBack: ()->Unit,
+ onInvite: ()->Unit,
+ onReinvite: (String)->Unit,
+ onPromote: (String)->Unit,
+ onRemove: (String)->Unit,
+ onEditName: ()->Unit,
+ viewState: EditGroupViewState,
+) {
+ val scaffoldState = rememberScaffoldState()
+
+ Scaffold(
+ scaffoldState = scaffoldState,
+ topBar = {
+ NavigationBar(
+ title = stringResource(id = R.string.activity_edit_closed_group_title),
+ onBack = onBack,
+ actionElement = {}
+ )
+ }
+ ) { paddingValues ->
+ Column(modifier = Modifier.padding(paddingValues)) {
+ // Group name title
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = viewState.groupName,
+ fontSize = 26.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ if (viewState.admin) {
+ Icon(
+ painterResource(R.drawable.ic_baseline_edit_24),
+ null,
+ modifier = Modifier
+ .padding(8.dp)
+ .size(16.dp)
+ .clip(CircleShape)
+ .align(CenterVertically)
+ .clickable { onEditName() }
+ )
+ }
+ }
+ // Description
+
+ // Invite
+ if (viewState.admin) {
+ CellWithPaddingAndMargin(margin = 16.dp, padding = 16.dp) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onInvite)
+ .padding(horizontal = 8.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ Icon(painterResource(id = R.drawable.ic_add_admins), contentDescription = null)
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(text = stringResource(id = R.string.activity_edit_closed_group_add_members))
+ }
+ }
+ }
+ // members header
+ Text(
+ text = stringResource(id = R.string.conversation_settings_group_members),
+ style = MaterialTheme.typography.subtitle2,
+ modifier = Modifier
+ .padding(vertical = 8.dp, horizontal = 32.dp)
+ )
+ LazyColumn(modifier = Modifier) {
+
+ items(viewState.memberStateList) { member ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp, horizontal = 16.dp)) {
+ ContactPhoto(member.memberSessionId)
+ Column(modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(horizontal = 8.dp)
+ .align(CenterVertically)) {
+ // Member's name
+ Text(
+ text = member.memberName ?: member.memberSessionId,
+ style = MemberNameStyle,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(1.dp)
+ )
+ if (member.memberState !in listOf(MemberState.Member, MemberState.Admin)) {
+ Text(
+ text = member.memberState.toString(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(1.dp)
+ )
+ }
+ }
+ // Resend button
+ if (viewState.admin && member.memberState == MemberState.InviteFailed) {
+ TextButton(
+ onClick = {
+ onReinvite(member.memberSessionId)
+ },
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(
+ Color(
+ MaterialColors.getColor(
+ LocalContext.current,
+ R.attr.colorControlHighlight,
+ MaterialTheme.colors.onPrimary.toArgb()
+ )
+ )
+ )
+ ) {
+ Text(
+ "Re-send",
+ color = MaterialTheme.colors.onPrimary
+ )
+ }
+ } else if (viewState.admin && member.memberState == MemberState.Member) {
+ TextButton(
+ onClick = {
+ onPromote(member.memberSessionId)
+ },
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(
+ Color(
+ MaterialColors.getColor(
+ LocalContext.current,
+ R.attr.colorControlHighlight,
+ MaterialTheme.colors.onPrimary.toArgb()
+ )
+ )
+ )
+ ) {
+ Text(
+ "Promote",
+ color = MaterialTheme.colors.onPrimary
+ )
+ }
+ TextButton(
+ onClick = {
+ onRemove(member.memberSessionId)
+ },
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(
+ Color(
+ MaterialColors.getColor(
+ LocalContext.current,
+ R.attr.colorControlHighlight,
+ MaterialTheme.colors.onPrimary.toArgb()
+ )
+ )
+ )
+ ) {
+ Icon(painter = painterResource(id = R.drawable.ic_baseline_close_24), contentDescription = null)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+data class EditGroupState(
+ val viewState: EditGroupViewState,
+ val eventSink: (EditGroupEvent) -> Unit
+)
+
+data class EditGroupInviteState(
+ val viewState: EditGroupInviteViewState,
+)
+
+data class MemberViewModel(
+ val memberName: String?,
+ val memberSessionId: String,
+ val memberState: MemberState,
+ val currentUser: Boolean,
+)
+
+enum class MemberState {
+ InviteSent,
+ Inviting, // maybe just use these in view
+ InviteFailed,
+ PromotionSent,
+ Promoting, // maybe just use these in view
+ PromotionFailed,
+ Admin,
+ Member
+}
+
+fun memberStateOf(member: GroupMember): MemberState = when {
+ member.inviteFailed -> MemberState.InviteFailed
+ member.invitePending -> MemberState.InviteSent
+ member.promotionFailed -> MemberState.PromotionFailed
+ member.promotionPending -> MemberState.PromotionSent
+ member.admin -> MemberState.Admin
+ else -> MemberState.Member
+}
+
+data class EditGroupViewState(
+ val groupName: String,
+ val groupDescription: String?,
+ val memberStateList: List,
+ val admin: Boolean
+)
+
+sealed class EditGroupEvent {
+ data class InviteContacts(val context: Context,
+ val contacts: ContactList): EditGroupEvent()
+ data class ReInviteContact(val contactSessionId: String): EditGroupEvent()
+ data class PromoteContact(val contactSessionId: String): EditGroupEvent()
+ data class RemoveContact(val contactSessionId: String): EditGroupEvent()
+ data class ChangeName(val newName: String): EditGroupEvent()
+}
+
+data class EditGroupInviteViewState(
+ val currentMembers: List,
+ val allContacts: Set
+)
+
+@Preview
+@Composable
+fun PreviewDialogChange(@PreviewParameter(ThemeResPreviewParameterProvider::class) styleRes: Int) {
+
+ PreviewTheme(themeResId = styleRes) {
+ EditClosedGroupView {
+
+ }
+ }
+
+}
+
+@Preview
+@Composable
+fun PreviewList() {
+
+ PreviewTheme(themeResId = R.style.Classic_Dark) {
+
+ 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 viewState = EditGroupViewState(
+ "Preview",
+ "This is a preview description",
+ listOf(oneMember, twoMember, threeMember),
+ true
+ )
+
+ EditGroupView(
+ onBack = {},
+ onInvite = {},
+ onReinvite = {},
+ onPromote = {},
+ onRemove = {},
+ onEditName = {},
+ viewState = viewState
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupNavGraph.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupNavGraph.kt
new file mode 100644
index 000000000..b3cd75fee
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupNavGraph.kt
@@ -0,0 +1,8 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import com.ramcosta.composedestinations.annotation.NavGraph
+
+@NavGraph
+annotation class EditGroupNavGraph(
+ val start: Boolean = false
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContacts.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContacts.kt
new file mode 100644
index 000000000..cffb0b04a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContacts.kt
@@ -0,0 +1,129 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+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.foundation.shape.CircleShape
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedButton
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+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.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import network.loki.messenger.R
+import org.session.libsession.messaging.contacts.Contact
+import org.thoughtcrime.securesms.home.search.getSearchName
+import org.thoughtcrime.securesms.ui.CloseIcon
+import org.thoughtcrime.securesms.ui.NavigationBar
+import org.thoughtcrime.securesms.ui.PreviewTheme
+import org.thoughtcrime.securesms.ui.SearchBar
+import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SelectContacts(
+ contactListState: Set,
+ onBack: ()->Unit,
+ onClose: (()->Unit)? = null,
+ onContactsSelected: (Set) -> Unit,
+ @StringRes okButtonResId: Int = R.string.ok
+) {
+
+ var queryFilter by remember { mutableStateOf("") }
+
+ // May introduce more advanced filters
+ val filtered = if (queryFilter.isEmpty()) contactListState.toList()
+ else {
+ contactListState
+ .filter { contact ->
+ contact.getSearchName().lowercase()
+ .contains(queryFilter.lowercase())
+ }
+ .toList()
+ }
+
+ var selected by remember {
+ mutableStateOf(emptySet())
+ }
+
+ Column {
+ NavigationBar(
+ title = stringResource(id = R.string.activity_create_closed_group_select_contacts),
+ onBack = onBack,
+ actionElement = {
+ if (onClose != null) {
+ CloseIcon(onClose)
+ }
+ }
+ )
+
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ stickyHeader {
+ // Search Bar
+ SearchBar(queryFilter, onValueChanged = { value -> queryFilter = value })
+ }
+
+ multiSelectMemberList(
+ contacts = filtered.toList(),
+ selectedContacts = selected,
+ onListUpdated = { selected = it },
+ )
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth()
+ .background(
+ verticalGradient(
+ 0f to Color.Transparent,
+ 0.2f to MaterialTheme.colors.primaryVariant,
+ )
+ )
+ ) {
+ OutlinedButton(
+ onClick = { onContactsSelected(selected) },
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp).defaultMinSize(minWidth = 128.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.onPrimary),
+ shape = CircleShape,
+ colors = ButtonDefaults.outlinedButtonColors(
+ backgroundColor = Color.Transparent,
+ contentColor = MaterialTheme.colors.onPrimary,
+ )
+ ) {
+ Text(
+ stringResource(id = okButtonResId)
+ )
+ }
+ }
+ }
+
+}
+
+@Preview
+@Composable
+fun previewSelectContacts(
+ @PreviewParameter(ThemeResPreviewParameterProvider::class) themeRes: Int
+) {
+ val empty = emptySet()
+ PreviewTheme(themeResId = themeRes) {
+ SelectContacts(contactListState = empty, onBack = { /*TODO*/ }, onContactsSelected = {})
+ }
+}
+
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 f25545572..68255bffd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
@@ -145,7 +145,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
push(intent)
}
- is GlobalSearchAdapter.Model.GroupConversation -> {
+ is GlobalSearchAdapter.Model.LegacyGroupConversation -> {
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
if (threadId >= 0) {
@@ -258,7 +258,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
globalSearchViewModel.result.collect { result ->
val currentUserPublicKey = publicKey
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } +
- result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) }
+ result.threads.map { GlobalSearchAdapter.Model.LegacyGroupConversation(it) }
val contactResults = contactAndGroupList.toMutableList()
@@ -334,9 +334,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
private fun setupMessageRequestsBanner() {
- val messageRequestCount = threadDb.unapprovedConversationCount
+ val messageRequestCount = threadDb.unapprovedConversationList.use { it.count }
// Set up message requests
- if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) {
+ if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests() && messageRequestCount != homeAdapter.requestCount) {
with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) {
unreadCountTextView.text = messageRequestCount.toString()
timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(
@@ -352,13 +352,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
if (hadHeader) homeAdapter.notifyItemChanged(0)
else homeAdapter.notifyItemInserted(0)
}
- } else {
+ } else if (messageRequestCount == 0) {
val hadHeader = homeAdapter.hasHeaderView()
homeAdapter.header = null
if (hadHeader) {
homeAdapter.notifyItemRemoved(0)
}
}
+ homeAdapter.requestCount = messageRequestCount
}
private fun updateLegacyConfigView() {
@@ -644,14 +645,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Cancel any outstanding jobs
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()
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
?.let { MessageSender.explicitLeave(it, false) }
- } catch (_: IOException) {
+ } catch (e: IOException) {
+ Log.e("Loki", e)
}
}
+ if (recipient.address.isClosedGroup) {
+ TODO("Implement leaving / deleting a new closed group conversation")
+ }
// Delete the conversation
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt
index eaf242aae..b47f77343 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt
@@ -38,6 +38,7 @@ class HomeAdapter(
}
fun hasHeaderView(): Boolean = header != null
+ var requestCount = 0
private val headerCount: Int
get() = if (header == null) 0 else 1
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 7cf953be2..ffa7fba3b 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
@@ -9,9 +9,10 @@ import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
+import network.loki.messenger.libsession_util.util.GroupInfo
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
-import org.thoughtcrime.securesms.mms.GlideApp
+import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.search.model.MessageResult
import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel
@@ -98,7 +99,7 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
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)
@@ -119,7 +120,8 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
data class Header(@StringRes val title: Int) : Model()
data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel) : Model()
- data class GroupConversation(val groupRecord: GroupRecord) : Model()
+ data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model()
+ data class ClosedGroupConversation(val sessionId: SessionId)
data class Message(val messageResult: MessageResult, val unread: Int) : 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 5371bb71c..09b972146 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
@@ -11,7 +11,7 @@ import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
-import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
+import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.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
@@ -65,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
}
- is GroupConversation -> {
+ is LegacyGroupConversation -> {
binding.searchResultTitle.text = getHighlight(
query,
model.groupRecord.title
@@ -86,10 +86,10 @@ 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.searchResultSavedMessages.isVisible = false
- 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)
@@ -102,7 +102,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
val address = it.address.serialize()
it.name ?: "${address.take(4)}...${address.takeLast(4)}"
}
- if (model.groupRecord.isClosedGroup) {
+ if (model.groupRecord.isLegacyClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
@@ -132,11 +132,6 @@ fun ContentView.bindModel(query: String?, model: Message) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultTimestamp.isVisible = true
-// val hasUnreads = model.unread > 0
-// binding.unreadCountIndicator.isVisible = hasUnreads
-// if (hasUnreads) {
-// binding.unreadCountTextView.text = model.unread.toString()
-// }
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()
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 10142cc8f..ae2d82577 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messagerequests
import android.content.Context
import android.content.res.ColorStateList
import android.database.Cursor
+import android.os.Build
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
@@ -61,10 +62,14 @@ class MessageRequestsAdapter(
val item = popupMenu.menu.getItem(i)
val s = SpannableString(item.title)
s.setSpan(ForegroundColorSpan(context.getColor(R.color.destructive)), 0, s.length, 0)
- item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive))
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive))
+ }
item.title = s
}
- popupMenu.setForceShowIcon(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ popupMenu.setForceShowIcon(true)
+ }
popupMenu.show()
}
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 e583fb0ca..b9adac4f4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt
@@ -7,8 +7,8 @@ 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
@@ -18,7 +18,7 @@ import nl.komponents.kovenant.functional.bind
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.utilities.TextSecurePreferences
@@ -121,7 +121,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
// 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.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
index 2c70bff63..b26cf8743 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
@@ -42,7 +42,6 @@ import com.goterl.lazysodium.utils.KeyPair;
import org.session.libsession.messaging.open_groups.OpenGroup;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
-import org.session.libsession.messaging.utilities.SessionId;
import org.session.libsession.messaging.utilities.SodiumUtilities;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
@@ -52,6 +51,7 @@ import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.IdPrefix;
import org.session.libsignal.utilities.Log;
+import org.session.libsignal.utilities.SessionId;
import org.session.libsignal.utilities.Util;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.contacts.ContactUtil;
@@ -555,7 +555,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
if (openGroup != null && edKeyPair != null) {
KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair);
if (blindedKeyPair != null) {
- return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
+ return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).hexString();
}
}
return null;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt
index b0954f232..8d068690d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt
@@ -73,7 +73,7 @@ class PushRegistry @Inject constructor(
token: String,
publicKey: String,
userEd25519Key: KeyPair,
- namespaces: List = listOf(Namespace.DEFAULT)
+ namespaces: List = listOf(Namespace.DEFAULT())
): Promise<*, Exception> {
Log.d(TAG, "register() called")
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt
index 4bef45ff9..d05eb2a0e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt
@@ -53,7 +53,7 @@ class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver)
val requestParameters = SubscriptionRequest(
pubkey = publicKey,
session_ed25519 = userEd25519Key.publicKey.asHexString,
- namespaces = listOf(Namespace.DEFAULT),
+ namespaces = listOf(Namespace.DEFAULT()),
data = true, // only permit data subscription for now (?)
service = device.service,
sig_ts = timestamp,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
index dbe09668c..85d97d8b5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import network.loki.messenger.R
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.Storage
@@ -28,7 +29,7 @@ import org.thoughtcrime.securesms.util.adapter.SelectableItem
import javax.inject.Inject
@HiltViewModel
-class BlockedContactsViewModel @Inject constructor(private val storage: Storage): ViewModel() {
+class BlockedContactsViewModel @Inject constructor(private val storage: StorageProtocol): ViewModel() {
private val executor = viewModelScope + SupervisorJob()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt
index 37a54a4af..b6e2f2f1a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt
@@ -104,7 +104,8 @@ class ClearAllDataDialog : DialogFragment() {
if (!deleteNetworkMessages) {
try {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get()
+ // TODO: maybe convert this to a blocking config job
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext())
} catch (e: Exception) {
Log.e("Loki", "Failed to force sync", e)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java
index 1c05e68bd..b656eb620 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java
@@ -9,11 +9,10 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
-import org.session.libsession.messaging.utilities.SessionId;
+import org.session.libsignal.utilities.SessionId;
import org.thoughtcrime.securesms.components.ProfilePictureView;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.database.model.MessageId;
-import org.thoughtcrime.securesms.mms.GlideApp;
import java.util.Collections;
import java.util.List;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt
index 2c0b39d69..940468b5d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt
@@ -77,7 +77,7 @@ interface ConversationRepository {
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf
- fun declineMessageRequest(threadId: Long)
+ fun declineMessageRequest(threadId: Long, recipient: Recipient)
fun hasReceived(threadId: Long): Boolean
@@ -286,8 +286,7 @@ class DefaultConversationRepository @Inject constructor(
}
override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf {
- sessionJobDb.cancelPendingMessageSendJobs(thread.threadId)
- storage.deleteConversation(thread.threadId)
+ declineMessageRequest(thread.threadId, thread.recipient)
return ResultOf.Success(Unit)
}
@@ -306,19 +305,27 @@ class DefaultConversationRepository @Inject constructor(
override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation ->
storage.setRecipientApproved(recipient, true)
- val message = MessageRequestResponse(true)
- MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
- .success {
- threadDb.setHasSent(threadId, true)
- continuation.resume(ResultOf.Success(Unit))
- }.fail { error ->
- continuation.resumeWithException(error)
- }
+ if (recipient.isClosedGroupRecipient) {
+ storage.respondToClosedGroupInvitation(recipient, true)
+ } else {
+ val message = MessageRequestResponse(true)
+ MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
+ .success {
+ threadDb.setHasSent(threadId, true)
+ continuation.resume(ResultOf.Success(Unit))
+ }.fail { error ->
+ continuation.resumeWithException(error)
+ }
+ }
}
- override fun declineMessageRequest(threadId: Long) {
+ override fun declineMessageRequest(threadId: Long, recipient: Recipient) {
sessionJobDb.cancelPendingMessageSendJobs(threadId)
- storage.deleteConversation(threadId)
+ if (recipient.isClosedGroupRecipient) {
+ storage.respondToClosedGroupInvitation(recipient, false)
+ } else {
+ storage.deleteConversation(threadId)
+ }
}
override fun hasReceived(threadId: Long): Boolean {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt
index 8b1975865..f211460b5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt
@@ -5,11 +5,11 @@ import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
-import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
+import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt
index 1724bde8a..0183b08b2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt
@@ -2,18 +2,28 @@ package org.thoughtcrime.securesms.ui
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.ButtonColors
import androidx.compose.material.Card
import androidx.compose.material.Colors
@@ -22,15 +32,24 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
+import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.pager.HorizontalPagerIndicator
import kotlinx.coroutines.launch
@@ -95,7 +114,7 @@ fun CellWithPaddingAndMargin(
}
}
-private val Colors.cellColor: Color
+val Colors.cellColor: Color
@Composable
get() = LocalExtraColors.current.settingsBackground
@@ -180,3 +199,160 @@ fun RowScope.Avatar(recipient: Recipient) {
)
}
}
+
+@Composable
+fun EditableAvatar(
+ // TODO: add attachment-based state for current view rendering?
+ modifier: Modifier = Modifier
+) {
+ Box(modifier = modifier
+ .size(110.dp)
+ .padding(15.dp)
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.avatar_placeholder),
+ contentDescription = stringResource(
+ id = R.string.arrays__default
+ ),
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.cellColor, shape = CircleShape)
+ .padding(16.dp)
+ )
+ Image(
+ painter = painterResource(id = R.drawable.ic_plus),
+ contentDescription = null,
+ Modifier
+ .align(Alignment.BottomEnd)
+ .size(24.dp)
+ .background(colorDestructive, shape = CircleShape)
+ .padding(6.dp)
+ )
+ }
+}
+
+@Composable
+fun SearchBar(
+ query: String,
+ onValueChanged: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .background(MaterialTheme.colors.primaryVariant, RoundedCornerShape(100))
+ ) {
+ Image(
+ painterResource(id = R.drawable.ic_search_24),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(
+ MaterialTheme.colors.onPrimary
+ ),
+ modifier = Modifier.size(20.dp)
+ )
+
+ BasicTextField(
+ singleLine = true,
+// label = { Text(text = stringResource(id = R.string.search_contacts_hint),modifier=Modifier.padding(0.dp)) },
+ value = query,
+ onValueChange = onValueChanged,
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .padding(4.dp)
+ .weight(1f),
+ )
+ }
+}
+
+@Composable
+fun NavigationBar(
+ title: String,
+ titleAlignment: Alignment = Alignment.Center,
+ onBack: (() -> Unit)? = null,
+ actionElement: (@Composable BoxScope.() -> Unit)? = null
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)) {
+ // Optional back button, layout should still take up space
+ Box(modifier = Modifier
+ .fillMaxHeight()
+ .aspectRatio(1.0f, true)
+ .padding(16.dp)
+ ) {
+ if (onBack != null) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_arrow_left_24),
+ contentDescription = stringResource(
+ id = R.string.new_conversation_dialog_back_button_content_description
+ ),
+ Modifier
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = rememberRipple(bounded = false, radius = 16.dp),
+ ) { onBack() }
+ .align(Alignment.Center)
+ )
+ }
+ }
+ //Main title
+ Box(modifier = Modifier
+ .fillMaxHeight()
+ .weight(1f)
+ .padding(8.dp)) {
+ Text(
+ text = title,
+ Modifier.align(titleAlignment),
+ overflow = TextOverflow.Ellipsis,
+ fontSize = 26.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ // Optional action
+ if (actionElement != null) {
+ Box(modifier = Modifier
+ .fillMaxHeight()
+ .align(Alignment.CenterVertically)
+ .aspectRatio(1.0f, true),
+ contentAlignment = Alignment.Center
+ ) {
+ actionElement(this)
+ }
+ }
+ }
+}
+
+@Composable
+fun BoxScope.CloseIcon(onClose: ()->Unit) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_baseline_close_24),
+ contentDescription = stringResource(
+ id = R.string.new_conversation_dialog_close_button_content_description
+ ),
+ Modifier
+ .clickable { onClose() }
+ .align(Alignment.Center)
+ .padding(16.dp)
+ )
+}
+
+@Composable
+@Preview
+fun PreviewNavigationBar(@PreviewParameter(provider = ThemeResPreviewParameterProvider::class) themeResId: Int) {
+ PreviewTheme(themeResId = themeResId) {
+ NavigationBar(title = "Create Group", onBack = {}, actionElement = {
+ CloseIcon {}
+ })
+ }
+}
+
+@Composable
+@Preview
+fun PreviewSearchBar(@PreviewParameter(provider = ThemeResPreviewParameterProvider::class) themeResId: Int) {
+ PreviewTheme(themeResId = themeResId) {
+ SearchBar("", {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt
index 64bbd21d8..95f754557 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt
@@ -18,10 +18,12 @@ import com.google.android.material.color.MaterialColors
import network.loki.messenger.R
val LocalExtraColors = staticCompositionLocalOf { error("No Custom Attribute value provided") }
+val LocalPreviewMode = staticCompositionLocalOf { false }
data class ExtraColors(
val settingsBackground: Color,
+ val destructive: Color
)
/**
@@ -34,6 +36,7 @@ fun AppTheme(
val extraColors = LocalContext.current.run {
ExtraColors(
settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground),
+ destructive = Color(getColor(R.color.destructive)),
)
}
@@ -56,7 +59,8 @@ fun PreviewTheme(
content: @Composable () -> Unit
) {
CompositionLocalProvider(
- LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId)
+ LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId),
+ LocalPreviewMode provides true
) {
AppTheme {
Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt
new file mode 100644
index 000000000..398bcbcc0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt
@@ -0,0 +1,35 @@
+package org.thoughtcrime.securesms.util
+
+import org.session.libsession.messaging.MessagingModuleConfiguration
+import org.session.libsession.messaging.jobs.AttachmentDownloadJob
+import org.session.libsession.messaging.jobs.JobQueue
+import org.session.libsession.messaging.sending_receiving.attachments.Attachment
+import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
+import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
+
+private const val ZERO_SIZE = "0.00"
+private const val KILO_SIZE = 1024f
+private const val MB_SUFFIX = "MB"
+private const val KB_SUFFIX = "KB"
+
+fun Attachment.displaySize(): String {
+
+ val kbSize = size / KILO_SIZE
+ val needsMb = kbSize > KILO_SIZE
+ val sizeText = "%.2f".format(if (needsMb) kbSize / KILO_SIZE else kbSize)
+ val displaySize = when {
+ sizeText == ZERO_SIZE -> "0.01"
+ sizeText.endsWith(".00") -> sizeText.takeWhile { it != '.' }
+ else -> sizeText
+ }
+ return "$displaySize${if (needsMb) MB_SUFFIX else KB_SUFFIX}"
+}
+
+fun JobQueue.createAndStartAttachmentDownload(attachment: DatabaseAttachment) {
+ val attachmentId = attachment.attachmentId.rowId
+ if (attachment.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
+ && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
+ // start download
+ add(AttachmentDownloadJob(attachmentId, attachment.mmsId))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt
index 297014d86..bd2358c8b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt
@@ -11,7 +11,6 @@ import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic
-import nl.komponents.kovenant.Promise
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.JobQueue
@@ -26,28 +25,48 @@ import org.session.libsession.utilities.WindowDebouncer
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
+import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.SessionId
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import java.util.Timer
+import java.util.concurrent.ConcurrentLinkedDeque
object ConfigurationMessageUtilities {
private val debouncer = WindowDebouncer(3000, Timer())
+ private val destinationUpdater = Any()
+ private val pendingDestinations = ConcurrentLinkedDeque()
- private fun scheduleConfigSync(userPublicKey: String) {
+ private fun scheduleConfigSync(destination: Destination) {
+ synchronized(destinationUpdater) {
+ pendingDestinations.add(destination)
+ }
debouncer.publish {
// don't schedule job if we already have one
val storage = MessagingModuleConfiguration.shared.storage
- val ourDestination = Destination.Contact(userPublicKey)
- val currentStorageJob = storage.getConfigSyncJob(ourDestination)
- if (currentStorageJob != null) {
- (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
- return@publish
+ val configFactory = MessagingModuleConfiguration.shared.configFactory
+ val destinations = synchronized(destinationUpdater) {
+ val objects = pendingDestinations.toList()
+ pendingDestinations.clear()
+ objects
+ }
+ destinations.forEach { destination ->
+ if (destination is Destination.ClosedGroup) {
+ // ensure we have the appropriate admin keys, skip this destination otherwise
+ val group = configFactory.userGroups?.getClosedGroup(destination.publicKey) ?: return@forEach
+ if (group.adminKey.isEmpty()) return@forEach Log.w("ConfigurationSync", "Trying to schedule config sync for group we aren't an admin of")
+ }
+ val currentStorageJob = storage.getConfigSyncJob(destination)
+ if (currentStorageJob != null) {
+ (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
+ return@publish
+ }
+ val newConfigSync = ConfigurationSyncJob(destination)
+ JobQueue.shared.add(newConfigSync)
}
- val newConfigSync = ConfigurationSyncJob(ourDestination)
- JobQueue.shared.add(newConfigSync)
}
}
@@ -58,7 +77,7 @@ object ConfigurationMessageUtilities {
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
- scheduleConfigSync(userPublicKey)
+ scheduleConfigSync(Destination.Contact(userPublicKey))
return
}
val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context)
@@ -82,34 +101,21 @@ object ConfigurationMessageUtilities {
TextSecurePreferences.setLastConfigurationSyncTime(context, now)
}
- fun forceSyncConfigurationNowIfNeeded(context: Context): Promise {
+ fun forceSyncConfigurationNowIfNeeded(destination: Destination) {
+ scheduleConfigSync(destination)
+ }
+
+
+ fun forceSyncConfigurationNowIfNeeded(context: Context) {
// add if check here to schedule new config job process and return early
- val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null"))
+ val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.e("Loki", NullPointerException("User Public Key is null"))
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
// schedule job if none exist
// don't schedule job if we already have one
- scheduleConfigSync(userPublicKey)
- return Promise.ofSuccess(Unit)
+ scheduleConfigSync(Destination.Contact(userPublicKey))
}
- val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
- !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
- }.map { recipient ->
- ConfigurationMessage.Contact(
- publicKey = recipient.address.serialize(),
- name = recipient.name!!,
- profilePicture = recipient.profileAvatar,
- profileKey = recipient.profileKey,
- isApproved = recipient.isApproved,
- isBlocked = recipient.isBlocked,
- didApproveMe = recipient.hasApprovedMe()
- )
- }
- val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit)
- val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true)
- TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis())
- return promise
}
private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes
@@ -199,6 +205,11 @@ object ConfigurationMessageUtilities {
convoConfig.getOrConstructCommunity(base, room, pubKey)
}
recipient.isClosedGroupRecipient -> {
+ // It's probably safe to assume there will never be a case where new closed groups will ever be there before a dump is created...
+ // but just in case...
+ convoConfig.getOrConstructClosedGroup(recipient.address.serialize())
+ }
+ recipient.isLegacyClosedGroupRecipient -> {
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
convoConfig.getOrConstructLegacyGroup(groupPublicKey)
}
@@ -241,7 +252,7 @@ object ConfigurationMessageUtilities {
}
val allLgc = storage.getAllGroups(includeInactive = false).filter {
- it.isClosedGroup && it.isActive && it.members.size > 1
+ it.isLegacyClosedGroup && it.isActive && it.members.size > 1
}.mapNotNull { group ->
val groupAddress = Address.fromSerialized(group.encodedId)
val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString()
@@ -252,7 +263,7 @@ object ConfigurationMessageUtilities {
val admins = group.admins.map { it.serialize() to true }.toMap()
val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap()
GroupInfo.LegacyGroupInfo(
- sessionId = groupPublicKey,
+ sessionId = SessionId.from(groupPublicKey),
name = group.title,
members = admins + members,
priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
@@ -273,13 +284,13 @@ object ConfigurationMessageUtilities {
@JvmField
val DELETE_INACTIVE_GROUPS: String = """
- DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%');
- DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%');
+ DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%');
+ DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%');
""".trimIndent()
@JvmField
val DELETE_INACTIVE_ONE_TO_ONES: String = """
- DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%';
+ DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%';
""".trimIndent()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt
index b15d82a33..a259df2e5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt
@@ -13,6 +13,8 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool
&& recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) {
return getOneToOne(recipient.address.serialize())?.unread == true
} else if (recipient.isClosedGroupRecipient) {
+ return getClosedGroup(recipient.address.serialize())?.unread == true
+ } else if (recipient.isLegacyClosedGroupRecipient) {
return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true
} else if (recipient.isOpenGroupRecipient) {
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false
diff --git a/app/src/main/res/drawable/avatar_placeholder.xml b/app/src/main/res/drawable/avatar_placeholder.xml
new file mode 100644
index 000000000..0e0f28541
--- /dev/null
+++ b/app/src/main/res/drawable/avatar_placeholder.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/debug_border.xml b/app/src/main/res/drawable/debug_border.xml
new file mode 100644
index 000000000..e0a28b77e
--- /dev/null
+++ b/app/src/main/res/drawable/debug_border.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_add_admins.xml b/app/src/main/res/drawable/ic_add_admins.xml
new file mode 100644
index 000000000..b0b326ca3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add_admins.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_all_media.xml b/app/src/main/res/drawable/ic_all_media.xml
new file mode 100644
index 000000000..a9b3bdfdd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_all_media.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_clear_messages.xml b/app/src/main/res/drawable/ic_clear_messages.xml
new file mode 100644
index 000000000..e79703910
--- /dev/null
+++ b/app/src/main/res/drawable/ic_clear_messages.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 000000000..f3db9cc27
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_disappearing_messages.xml b/app/src/main/res/drawable/ic_disappearing_messages.xml
new file mode 100644
index 000000000..1e2de4e75
--- /dev/null
+++ b/app/src/main/res/drawable/ic_disappearing_messages.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_group.xml b/app/src/main/res/drawable/ic_edit_group.xml
new file mode 100644
index 000000000..f647fea3e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_group.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_leave_group.xml b/app/src/main/res/drawable/ic_leave_group.xml
new file mode 100644
index 000000000..a6a235aeb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_leave_group.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_media.xml b/app/src/main/res/drawable/ic_media.xml
new file mode 100644
index 000000000..16b35db22
--- /dev/null
+++ b/app/src/main/res/drawable/ic_media.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification_settings.xml b/app/src/main/res/drawable/ic_notification_settings.xml
new file mode 100644
index 000000000..e3dea6f2a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_settings.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_pin_conversation.xml b/app/src/main/res/drawable/ic_pin_conversation.xml
new file mode 100644
index 000000000..b2ff304b3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pin_conversation.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_search_conversation.xml b/app/src/main/res/drawable/ic_search_conversation.xml
new file mode 100644
index 000000000..bd9eaad36
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search_conversation.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_conversation_notification_settings.xml b/app/src/main/res/layout/activity_conversation_notification_settings.xml
new file mode 100644
index 000000000..5482601ca
--- /dev/null
+++ b/app/src/main/res/layout/activity_conversation_notification_settings.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_conversation_settings.xml b/app/src/main/res/layout/activity_conversation_settings.xml
new file mode 100644
index 000000000..c12a9857e
--- /dev/null
+++ b/app/src/main/res/layout/activity_conversation_settings.xml
@@ -0,0 +1,382 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_edit_closed_group.xml b/app/src/main/res/layout/activity_edit_closed_group.xml
index e8a892f18..298bdfcbd 100644
--- a/app/src/main/res/layout/activity_edit_closed_group.xml
+++ b/app/src/main/res/layout/activity_edit_closed_group.xml
@@ -4,7 +4,7 @@
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
- tools:context="org.thoughtcrime.securesms.groups.EditClosedGroupActivity">
+ tools:context="org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_clear_all_messages.xml b/app/src/main/res/layout/dialog_clear_all_messages.xml
new file mode 100644
index 000000000..b04995227
--- /dev/null
+++ b/app/src/main/res/layout/dialog_clear_all_messages.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/media_overview_activity.xml b/app/src/main/res/layout/media_overview_activity.xml
index 6956adc45..c92355283 100644
--- a/app/src/main/res/layout/media_overview_activity.xml
+++ b/app/src/main/res/layout/media_overview_activity.xml
@@ -17,7 +17,17 @@
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:attr/actionBarSize"
- app:layout_scrollFlags="scroll|enterAlways"/>
+ app:layout_scrollFlags="scroll|enterAlways">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_pending_attachment.xml b/app/src/main/res/layout/view_pending_attachment.xml
new file mode 100644
index 000000000..cb28cfac5
--- /dev/null
+++ b/app/src/main/res/layout/view_pending_attachment.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_untrusted_attachment.xml b/app/src/main/res/layout/view_untrusted_attachment.xml
deleted file mode 100644
index 92d1db04e..000000000
--- a/app/src/main/res/layout/view_untrusted_attachment.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml
index 2c56229a8..e6791e97d 100644
--- a/app/src/main/res/layout/view_visible_message_content.xml
+++ b/app/src/main/res/layout/view_visible_message_content.xml
@@ -26,13 +26,13 @@
android:layout_height="wrap_content"
/>
-
diff --git a/app/src/main/res/menu/menu_message_request.xml b/app/src/main/res/menu/menu_message_request.xml
index d9ff73764..fcaa8acb0 100644
--- a/app/src/main/res/menu/menu_message_request.xml
+++ b/app/src/main/res/menu/menu_message_request.xml
@@ -3,7 +3,7 @@
-
- @string/notify_type_all
- @string/notify_type_mentions
+ - @string/notify_type_mute
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 99e0aa197..8f67ae6ea 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -103,7 +103,7 @@
2dp
16dp
- 3
+ 4
10dp
@@ -149,5 +149,6 @@
34dp
24dp
+ 26dp
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
index cb9392f69..b64936bea 100644
--- a/app/src/main/res/values/ids.xml
+++ b/app/src/main/res/values/ids.xml
@@ -3,4 +3,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d794bf5f8..d4d63a7c6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -10,6 +10,7 @@
Please wait…
Save
Image
+ Video
Note to Self
Version %s
Expand
@@ -82,6 +83,9 @@
Done
Mentions list
Contact mentions
+ Group name
+ Group description
+ Create group
Call button
Settings
@@ -777,6 +781,7 @@
Please enter a group name
Please enter a shorter group name
Please pick at least 1 group member
+ You haven\'t added any members
A closed group cannot have more than 100 members
Join Open Group
Couldn\'t join group
@@ -898,8 +903,8 @@
Enable Link Previews?
Enabling link previews will show previews for URLs you send and receive. This can be useful, but Session will need to contact linked websites to generate previews. You can always disable link previews in Session\'s settings.
Enable
- Trust %s?
- Are you sure you want to download media sent by %s?
+ Auto Download?
+ Would you like to automatically download all files from %s?
Download
%s is blocked. Unblock them?
Block User
@@ -910,10 +915,12 @@
Warning
This is your recovery phrase. If you send it to someone they\'ll have full access to your account.
Send
- All
- Mentions
+ All Messages
+ Mentions Only
+ Mute
This message has been deleted
Delete just for me
+ Delete from all my devices
Delete for everyone
Delete for me and %s
Feedback/Survey
@@ -927,13 +934,14 @@
Messages
Message Requests
Sending a message to this user will automatically accept their message request and reveal your Session ID.
+ Sending a message to this group will automatically accept the group invite.
Accept
Decline
Clear All
Are you sure you want to decline this message request?
Are you sure you want to block this message request?
Message request deleted
- Are you sure you want to clear all message requests?
+ Are you sure you want to clear all message requests and group invites?
Message requests deleted
Your message request has been accepted.
Your message request is currently pending.
@@ -1045,4 +1053,36 @@
Unread Messages
+ Search Conversation
+ All Media
+ Pin Conversation
+ Unpin Conversation
+ Notifications
+ Auto-download Media
+ Automatically download media and files from this chat.
+ Admin Settings
+ Disappearing Messages
+ Edit Group
+ Add Admins
+ Clear Messages
+ Leave Group
+ Group Members
+ Delete Group
+ Clear All Messages
+ Clear All Media
+ Are you sure you want to clear all group messages?
+ Are you sure you want to clear all media and files? This will also delete all messages with attachments.
+ Are you sure you want to clear %s messages from your device?
+ For Me
+ For Everyone
+ Clear
+ Cancel
+ Clear All
+ Select Contacts
+ Add Account ID or ONS
+ Are you sure you want to leave %1$s?
+ Update Group Information
+ Group name and description is visible to all group members.
+ Enter group name
+ Enter group description
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 2928fc771..ee2c74617 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -13,6 +13,54 @@
- 0dp
+
+
+
+
+
+
+
+
+
+
+
+