This commit is contained in:
0x330a 2023-12-11 15:48:45 +11:00 committed by GitHub
commit 5ac4192539
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
200 changed files with 20202 additions and 4174 deletions

10
.drone.yml Normal file
View File

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

View File

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

View File

@ -1,8 +1,10 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="network.loki.messenger.test">
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<uses-library android:name="android.test.runner"
android:required="false" />
<activity android:name="androidx.activity.ComponentActivity"/>
</application>
</manifest>

View File

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

View File

@ -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<InputBar>(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<View>? {
return isRoot()
}
override fun getDescription(): String = "Wait for $millis milliseconds."
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadForAtLeast(millis)
}
}
}
}

View File

@ -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<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.Companion.newInstance(key)
@ -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))
}
}

View File

@ -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<Context>()
val emptyDb = mock<ConfigDatabase> { 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)
}

View File

@ -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<InputBar>(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<View>? {
return ViewMatchers.isRoot()
}
override fun getDescription(): String = "Wait for $millis milliseconds."
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadForAtLeast(millis)
}
}
}

View File

@ -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<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
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

View File

@ -152,8 +152,13 @@
android:theme="@style/Theme.Session.DayNight.FlatActionBar"
android:label="@string/blocked_contacts_title"
/>
<activity
android:name="org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity"
android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"
android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" />
<activity
@ -232,6 +237,13 @@
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.NoActionBar"/>
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationNotificationSettingsActivity"
android:label="@string/activity_notification_settings_title"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight"/>
<activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait"

View File

@ -32,11 +32,12 @@ import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner;
import org.conscrypt.Conscrypt;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.avatars.AvatarHelper;
import org.session.libsession.database.MessageDataProvider;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
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.Poller;
import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address;
@ -65,6 +66,7 @@ import org.thoughtcrime.securesms.dependencies.AppComponent;
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.dependencies.PollerFactory;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.home.HomeActivity;
@ -108,9 +110,8 @@ import javax.inject.Inject;
import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit;
import kotlinx.coroutines.Job;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.Config;
import network.loki.messenger.libsession_util.UserProfile;
/**
@ -136,7 +137,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public MessageNotifier messageNotifier = null;
public Poller poller = null;
public Broadcaster broadcaster = null;
private Job firebaseInstanceIdJob;
private WindowDebouncer conversationListDebouncer;
private HandlerThread conversationListHandlerThread;
private Handler conversationListHandler;
@ -149,6 +149,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject TextSecurePreferences textSecurePreferences;
@Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory;
@Inject PollerFactory pollerFactory;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@ -197,7 +198,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
@Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
public void notifyUpdates(@NotNull Config forConfigObject) {
// forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true);
@ -219,7 +220,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
messageDataProvider,
()-> 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);

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<MediaRecord> 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<MediaRecord> 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);
}
}

View File

@ -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<MediaGalleryAdapter.ViewHolder>() {
private val items: MutableList<MediaRecord> = mutableListOf()
private val selectedItems: MutableSet<MediaRecord> = mutableSetOf()
fun setItems(newItems: List<MediaRecord>) {
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?)
}
}

View File

@ -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<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
@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<BucketedThreadMedia>
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<BucketedThreadMedia> 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<BucketedThreadMedia> 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<BucketedThreadMedia> 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<MediaDatabase.MediaRecord> 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<Void, Void, List<SaveAttachmentTask.Attachment>>(
context,
R.string.MediaOverviewActivity_collecting_attachments,
R.string.please_wait) {
@Override
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
List<SaveAttachmentTask.Attachment> 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<SaveAttachmentTask.Attachment> 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<MediaDatabase.MediaRecord> mediaRecords) {
int recordCount = mediaRecords.size();
DeleteMediaDialog.show(
requireContext(),
recordCount,
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
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<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
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<Cursor> {
@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<Cursor> onCreateLoader(int id, Bundle args) {
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
public static class MediaOverviewGalleryFragment
extends MediaOverviewFragment<Cursor>
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<Cursor> onCreateLoader(int i, Bundle bundle) {
return new ThreadMediaLoader(requireContext(), recipient.getAddress(), true);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
List<MediaDatabase.MediaRecord> 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<Cursor> 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<MediaDatabase.MediaRecord> 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<Void, Void, List<SaveAttachmentTask.Attachment>>(
context,
R.string.MediaOverviewActivity_collecting_attachments,
R.string.please_wait) {
@Override
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
List<SaveAttachmentTask.Attachment> 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<SaveAttachmentTask.Attachment> 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<MediaDatabase.MediaRecord> mediaRecords) {
int recordCount = mediaRecords.size();
DeleteMediaDialog.show(
requireContext(),
recordCount,
() ->
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(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<Cursor> loader, Cursor data) {
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data);
getActivity().invalidateOptionsMenu();
public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
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<Cursor> 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<Cursor> onCreateLoader(int id, Bundle args) {
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> 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<Cursor> loader) {
((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(null);
getActivity().invalidateOptionsMenu();
}
}
}
}

View File

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

View File

@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
return getItems(contacts, context.getString(R.string.fragment_contact_selection_closed_groups_title)) {
it.address.isClosedGroup
it.address.isLegacyClosedGroup || it.address.isClosedGroup
}
}

View File

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

View File

@ -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<Long, Unit>() {
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 */ }
}

View File

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

View File

@ -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<Long, ConversationSettingsActivityResult>() {
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
}
}

View File

@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationSettingsViewModel(threadId, storage, prefs) as T
}
}
}

View File

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

View File

@ -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<GroupMember>
get() {
val recipient = recipient ?: return emptyList()
if (!recipient.isClosedGroupRecipient) return emptyList()
return storage.getMembers(recipient.address.serialize())
}
val serverCapabilities: List<String>
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 <T : ViewModel> create(modelClass: Class<T>): T {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -267,7 +267,6 @@ class VisibleMessageView : LinearLayout {
glide,
thread,
searchQuery,
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
onAttachmentNeedsDownload
)
binding.messageContentView.root.delegate = delegate

View File

@ -11,23 +11,31 @@ object MentionManagerUtilities {
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
val result = mutableSetOf<String>()
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
}

View File

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

View File

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

View File

@ -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/%'");

View File

@ -826,23 +826,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
}
private fun deleteQuotedFromMessages(toDeleteRecords: List<MessageRecord>) {
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<String>()
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<String?>(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null)
val toDeleteStringMessageIds = mutableListOf<String>()
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<Attachment?, AttachmentId?>,
contacts: List<Contact?>
@ -1069,7 +1108,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false
}
/*package*/
private fun deleteThreads(threadIds: Set<Long>) {
val db = databaseHelper.writableDatabase
val where = StringBuilder()

View File

@ -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<String> 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<RecipientSettings> 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <T> synchronizedWithLog(lock: Any, body: ()->T): T {
private inline fun <T> 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<ConfigBase> =
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Contact>) : Parcelable
@CreateGroupNavGraph(start = true)
@Composable
@Destination
fun CreateGroupScreen(
navigator: DestinationsNavigator,
resultSelectContact: ResultRecipient<SelectContactsScreenDestination, ContactList>,
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<ContactList>,
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))
}
)
}

View File

@ -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<List<Recipient>>()
val recipients: LiveData<List<Recipient>> = _recipients
private inline fun <reified T> MutableLiveData<T>.update(body: T.() -> T) {
this.postValue(body(this.value!!))
}
init {
viewModelScope.launch {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val recipients = mutableListOf<Recipient>()
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> = _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<Recipient> {
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())
)
}
}
}

View File

@ -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<String>()
private val zombies = HashSet<String>()
private val members = HashSet<String>()
private val allMembers: Set<String>
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<View>(R.id.addMembersClosedGroupButton).setOnClickListener {
onAddMembersClick()
}
findViewById<RecyclerView>(R.id.rvUserList).apply {
adapter = memberListAdapter
layoutManager = LinearLayoutManager(this@EditClosedGroupActivity)
}
lblGroupNameDisplay.text = originalName
cntGroupNameDisplay.setOnClickListener { isEditingName = true }
findViewById<View>(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false }
findViewById<View>(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<GroupMembers> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID)
}
override fun onLoadFinished(loader: Loader<GroupMembers>, 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<GroupMembers>) {
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<Any, Exception> = 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<String>, val zombieMembers: List<String>)
}

View File

@ -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<EditClosedGroupActivity.GroupMembers>(context) {
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditLegacyClosedGroupActivity.GroupMembers>(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()
},

View File

@ -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<String>()
private val zombies = HashSet<String>()
private val members = HashSet<String>()
private val allMembers: Set<String>
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<View>(R.id.addMembersClosedGroupButton).setOnClickListener {
onAddMembersClick()
}
findViewById<RecyclerView>(R.id.rvUserList).apply {
adapter = memberListAdapter
layoutManager = LinearLayoutManager(this@EditLegacyClosedGroupActivity)
}
lblGroupNameDisplay.text = originalName
cntGroupNameDisplay.setOnClickListener { isEditingName = true }
findViewById<View>(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false }
findViewById<View>(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<GroupMembers> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
return EditClosedGroupLoader(this@EditLegacyClosedGroupActivity, groupID)
}
override fun onLoadFinished(loader: Loader<GroupMembers>, 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<GroupMembers>) {
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<Any, Exception> = 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<String>, val zombieMembers: List<String>)
}

View File

@ -0,0 +1,2 @@
package org.thoughtcrime.securesms.groups

View File

@ -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<Contact>,
modifier: Modifier = Modifier,
selectedContacts: Set<Contact> = emptySet(),
onListUpdated: (Set<Contact>)->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<Contact>,
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(),
)
}
}
}

View File

@ -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<Contact> = 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<Contact>): StateUpdate()
}

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.groups.compose
import com.ramcosta.composedestinations.annotation.NavGraph
@NavGraph
annotation class CreateGroupNavGraph(
val start: Boolean = false
)

View File

@ -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<EditClosedGroupInviteScreenDestination, ContactList>,
resultEditName: ResultRecipient<EditClosedGroupNameScreenDestination, String>,
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<String>,
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<ContactList>,
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<MemberViewModel>,
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<MemberViewModel>,
val allContacts: Set<Contact>
)
@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
)
}
}

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.groups.compose
import com.ramcosta.composedestinations.annotation.NavGraph
@NavGraph
annotation class EditGroupNavGraph(
val start: Boolean = false
)

View File

@ -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<Contact>,
onBack: ()->Unit,
onClose: (()->Unit)? = null,
onContactsSelected: (Set<Contact>) -> 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<Contact>())
}
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<Contact>()
PreviewTheme(themeResId = themeRes) {
SelectContacts(contactListState = empty, onBack = { /*TODO*/ }, onContactsSelected = {})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,7 @@ class PushRegistry @Inject constructor(
token: String,
publicKey: String,
userEd25519Key: KeyPair,
namespaces: List<Int> = listOf(Namespace.DEFAULT)
namespaces: List<Int> = listOf(Namespace.DEFAULT())
): Promise<*, Exception> {
Log.d(TAG, "register() called")

View File

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

View File

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

View File

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

View File

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

View File

@ -77,7 +77,7 @@ interface ConversationRepository {
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit>
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<Unit> {
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<Unit> = 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 {

View File

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

View File

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

View File

@ -18,10 +18,12 @@ import com.google.android.material.color.MaterialColors
import network.loki.messenger.R
val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { 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)) {

View File

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

View File

@ -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<Destination>()
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<Unit, Exception> {
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()
}

View File

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

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="35dp"
android:viewportWidth="44"
android:viewportHeight="35">
<path
android:pathData="M34.951,23.398L26.63,17.311C26.575,17.267 26.507,17.243 26.438,17.243C26.368,17.243 26.299,17.267 26.245,17.311L18.784,23.488C18.727,23.531 18.657,23.554 18.585,23.554C18.513,23.554 18.444,23.531 18.386,23.488L14.637,20.432C14.583,20.391 14.517,20.369 14.45,20.369C14.383,20.369 14.318,20.391 14.264,20.432L4.107,27.648C4.068,27.679 4.036,27.718 4.014,27.762C3.991,27.807 3.979,27.855 3.978,27.905V30.242C3.982,30.435 4.06,30.619 4.196,30.756C4.333,30.893 4.517,30.971 4.71,30.974H34.348C34.542,30.974 34.728,30.897 34.865,30.76C35.002,30.622 35.08,30.436 35.08,30.242V23.642C35.081,23.594 35.07,23.546 35.047,23.503C35.025,23.46 34.992,23.424 34.951,23.398Z"
android:fillColor="?colorOnSurface"/>
<path
android:pathData="M11.131,18.248C12.726,18.248 14.02,16.955 14.02,15.359C14.02,13.763 12.726,12.47 11.131,12.47C9.535,12.47 8.242,13.763 8.242,15.359C8.242,16.955 9.535,18.248 11.131,18.248Z"
android:fillColor="?colorOnSurface"/>
<path
android:pathData="M40.396,4.123L8.229,0.374C7.807,0.322 7.38,0.354 6.971,0.47C6.563,0.585 6.181,0.78 5.849,1.044C5.517,1.308 5.24,1.636 5.036,2.008C4.832,2.38 4.703,2.789 4.659,3.212L4.505,4.598H7.073L7.201,3.507C7.212,3.424 7.239,3.343 7.281,3.27C7.323,3.198 7.379,3.134 7.445,3.083C7.555,2.995 7.69,2.946 7.831,2.942H7.895L22.187,4.598H35.722C36.512,4.603 37.29,4.789 37.996,5.143C38.702,5.498 39.316,6.01 39.792,6.64H40.1C40.269,6.659 40.423,6.745 40.528,6.877C40.634,7.009 40.683,7.178 40.666,7.346L40.576,8.078C40.755,8.595 40.85,9.137 40.858,9.683V27.995L43.221,7.68C43.315,6.834 43.071,5.986 42.541,5.319C42.012,4.653 41.241,4.223 40.396,4.123Z"
android:fillColor="?colorOnSurface"/>
<path
android:pathData="M35.722,34.801H3.336C2.485,34.801 1.668,34.463 1.066,33.861C0.464,33.258 0.126,32.442 0.126,31.59V9.761C0.126,8.909 0.464,8.093 1.066,7.49C1.668,6.888 2.485,6.55 3.336,6.55H35.722C36.573,6.55 37.39,6.888 37.992,7.49C38.594,8.093 38.932,8.909 38.932,9.761V31.59C38.932,32.442 38.594,33.258 37.992,33.861C37.39,34.463 36.573,34.801 35.722,34.801ZM3.336,9.093C3.166,9.093 3.003,9.16 2.882,9.281C2.762,9.401 2.694,9.565 2.694,9.735V31.565C2.694,31.735 2.762,31.898 2.882,32.019C3.003,32.139 3.166,32.207 3.336,32.207H35.722C35.892,32.207 36.055,32.139 36.176,32.019C36.296,31.898 36.364,31.735 36.364,31.565V9.735C36.364,9.565 36.296,9.401 36.176,9.281C36.055,9.16 35.892,9.093 35.722,9.093H3.336Z"
android:fillColor="?colorOnSurface"/>
</vector>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<stroke android:color="@color/accent_green" android:width="2dp"/>
</shape>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.724h25v25h-25z"/>
<path
android:pathData="M21.717,3.724H5.05C3.945,3.724 2.886,4.171 2.104,4.968C1.323,5.764 0.884,6.845 0.884,7.971V18.476C0.884,19.603 1.323,20.683 2.104,21.479C2.886,22.276 3.945,22.724 5.05,22.724H21.717C22.822,22.724 23.882,22.276 24.663,21.479C25.445,20.683 25.884,19.603 25.884,18.476V7.98C25.885,7.421 25.778,6.868 25.569,6.352C25.36,5.835 25.053,5.366 24.666,4.971C24.279,4.575 23.82,4.262 23.314,4.048C22.808,3.834 22.265,3.724 21.717,3.724ZM23.8,18.484C23.8,19.048 23.581,19.588 23.19,19.986C22.799,20.385 22.27,20.608 21.717,20.608H5.05C4.498,20.608 3.968,20.385 3.577,19.986C3.187,19.588 2.967,19.048 2.967,18.484V7.98C2.967,7.417 3.187,6.876 3.577,6.478C3.968,6.08 4.498,5.856 5.05,5.856H21.717C22.27,5.856 22.799,6.08 23.19,6.478C23.581,6.876 23.8,7.417 23.8,7.98V18.484Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M17.401,12.172H14.692V9.466C14.692,9.185 14.583,8.915 14.387,8.716C14.192,8.516 13.927,8.405 13.651,8.405C13.374,8.405 13.109,8.516 12.914,8.716C12.719,8.915 12.609,9.185 12.609,9.466V12.172H10.026C9.749,12.172 9.484,12.284 9.289,12.483C9.094,12.682 8.984,12.953 8.984,13.234C8.984,13.516 9.094,13.786 9.289,13.985C9.484,14.184 9.749,14.296 10.026,14.296H12.625V16.998C12.625,17.279 12.735,17.549 12.931,17.749C13.126,17.948 13.391,18.06 13.667,18.06C13.943,18.06 14.208,17.948 14.404,17.749C14.599,17.549 14.709,17.279 14.709,16.998V14.296H17.401C17.677,14.296 17.942,14.184 18.137,13.985C18.333,13.786 18.442,13.516 18.442,13.234C18.442,12.953 18.333,12.682 18.137,12.483C17.942,12.284 17.677,12.172 17.401,12.172Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.988h25v25h-25z"/>
<path
android:pathData="M21.706,5.974L18.465,2.284C18.114,1.88 17.683,1.556 17.2,1.332C16.717,1.109 16.194,0.991 15.663,0.988H15.631L7.826,1.063C6.784,1.106 5.801,1.563 5.087,2.337C4.373,3.11 3.987,4.139 4.01,5.2V21.851C3.984,22.919 4.374,23.954 5.095,24.729C5.817,25.505 6.81,25.957 7.859,25.988H18.909C19.957,25.957 20.951,25.505 21.672,24.729C22.394,23.954 22.784,22.919 22.757,21.851V8.818C22.764,7.77 22.389,6.757 21.706,5.974ZM20.174,7.366H16.888C16.801,7.366 16.717,7.331 16.655,7.268C16.594,7.206 16.559,7.121 16.559,7.032V3.33C16.702,3.422 16.833,3.532 16.949,3.656L20.174,7.366ZM18.904,23.896H7.855C7.351,23.866 6.879,23.634 6.543,23.251C6.207,22.868 6.033,22.364 6.06,21.851V5.183C6.034,4.672 6.206,4.171 6.54,3.789C6.873,3.406 7.341,3.172 7.843,3.138L14.493,3.075V7.538C14.482,8.035 14.664,8.516 15.001,8.877C15.337,9.237 15.8,9.448 16.288,9.462H20.72V21.834C20.736,22.092 20.701,22.349 20.618,22.593C20.535,22.836 20.405,23.06 20.236,23.252C20.067,23.444 19.862,23.6 19.634,23.71C19.405,23.821 19.157,23.884 18.904,23.896Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.592h25v25h-25z"/>
<path
android:pathData="M18.935,2.796H3.719C2.156,2.796 0.884,4.044 0.884,5.578V19.263C0.882,19.459 0.935,19.653 1.037,19.824C1.139,19.994 1.286,20.136 1.464,20.235C1.636,20.331 1.831,20.382 2.03,20.383C2.228,20.384 2.424,20.335 2.596,20.24L6.502,18.118C6.777,17.968 7.088,17.888 7.405,17.887H18.934C20.497,17.887 21.769,16.639 21.769,15.105V5.583C21.77,4.046 20.498,2.796 18.935,2.796ZM20.746,15.105C20.746,16.099 19.934,16.908 18.935,16.908H7.406C6.913,16.91 6.429,17.034 5.999,17.266L2.091,19.389C2.074,19.399 2.055,19.404 2.035,19.404C2.015,19.404 1.996,19.399 1.979,19.389C1.956,19.376 1.937,19.358 1.925,19.336C1.912,19.313 1.906,19.288 1.908,19.263V5.578C1.908,4.584 2.72,3.775 3.719,3.775H18.935C19.934,3.775 20.746,4.586 20.746,5.583V15.105Z"
android:strokeWidth="0.7"
android:fillColor="#FF3A3A"
android:strokeColor="#FF3A3A"/>
<path
android:pathData="M14.642,14.23C14.642,14.879 15.062,15.376 15.598,15.568C15.598,15.568 15.598,15.569 15.598,15.569C15.536,15.75 15.515,15.943 15.537,16.135L15.537,16.135L15.537,16.139L16.376,23.226C16.376,23.227 16.377,23.228 16.377,23.229C16.416,23.564 16.583,23.862 16.829,24.071C17.075,24.28 17.383,24.388 17.695,24.388H17.695H23.833H23.833C24.144,24.388 24.452,24.28 24.698,24.071C24.944,23.862 25.112,23.564 25.151,23.229C25.151,23.228 25.151,23.227 25.151,23.226L25.99,16.139L25.99,16.139L25.991,16.135C26.013,15.943 25.991,15.75 25.93,15.569C25.929,15.569 25.929,15.568 25.929,15.567C26.464,15.375 26.884,14.878 26.884,14.23V13.134C26.884,12.296 26.182,11.711 25.436,11.711H16.09C15.344,11.711 14.642,12.296 14.642,13.134V14.23Z"
android:strokeWidth="2"
android:fillColor="#FF3A3A"
android:strokeColor="?colorPrimaryDark"/>
</group>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.46h25v25h-25z"/>
<path
android:pathData="M24.325,0.46H2.443C1.863,0.46 1.393,0.904 1.393,1.452V4.017C1.393,4.565 1.863,5.009 2.443,5.009H24.325C24.905,5.009 25.375,4.565 25.375,4.017V1.452C25.375,0.904 24.905,0.46 24.325,0.46Z"
android:fillColor="#FF3A3A"/>
<path
android:pathData="M22.533,7.405H4.239C4.131,7.405 4.024,7.427 3.925,7.469C3.827,7.51 3.738,7.571 3.666,7.647C3.594,7.723 3.54,7.813 3.506,7.91C3.473,8.007 3.462,8.11 3.474,8.211L5.44,24.814C5.46,24.992 5.549,25.156 5.689,25.275C5.83,25.394 6.012,25.46 6.2,25.46H20.572C20.761,25.46 20.943,25.394 21.083,25.275C21.223,25.156 21.312,24.992 21.333,24.814L23.299,8.211C23.31,8.11 23.299,8.007 23.266,7.91C23.233,7.813 23.178,7.723 23.106,7.647C23.034,7.571 22.945,7.51 22.847,7.469C22.748,7.427 22.641,7.405 22.533,7.405Z"
android:fillColor="#FF3A3A"/>
</group>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.158h25v25h-25z"/>
<path
android:pathData="M25.884,12.642C25.887,14.578 25.441,16.489 24.578,18.223C23.716,19.958 22.463,21.468 20.916,22.635C19.37,23.802 17.573,24.593 15.668,24.947C13.763,25.301 11.802,25.208 9.939,24.674C8.077,24.14 6.364,23.181 4.936,21.873C3.508,20.564 2.403,18.941 1.711,17.133C1.018,15.324 0.755,13.38 0.943,11.452C1.131,9.525 1.765,7.668 2.794,6.027C2.83,5.971 2.877,5.923 2.931,5.885C2.986,5.848 3.048,5.822 3.113,5.809C3.179,5.796 3.246,5.797 3.311,5.811C3.376,5.825 3.437,5.852 3.491,5.89L4.489,6.569C4.595,6.644 4.669,6.756 4.695,6.884C4.72,7.011 4.695,7.143 4.625,7.253C3.49,9.089 2.96,11.234 3.111,13.387C3.263,15.54 4.087,17.591 5.469,19.25C6.85,20.909 8.718,22.092 10.809,22.631C12.9,23.171 15.107,23.04 17.12,22.257C19.132,21.475 20.847,20.08 22.023,18.269C23.198,16.459 23.775,14.325 23.67,12.169C23.566,10.014 22.787,7.945 21.442,6.257C20.097,4.568 18.255,3.345 16.177,2.759C16.16,2.755 16.143,2.754 16.126,2.757C16.109,2.76 16.093,2.767 16.08,2.777C16.066,2.787 16.055,2.801 16.047,2.816C16.039,2.831 16.036,2.848 16.036,2.865V5.282C16.034,5.414 15.981,5.54 15.888,5.633C15.795,5.726 15.669,5.779 15.537,5.78H13.865C13.733,5.779 13.607,5.726 13.513,5.633C13.42,5.54 13.367,5.414 13.366,5.282V0.497C13.366,0.409 13.4,0.325 13.461,0.262C13.522,0.198 13.605,0.161 13.693,0.158H13.829C14.273,0.173 14.714,0.213 15.153,0.277C18.133,0.698 20.86,2.181 22.832,4.454C24.804,6.726 25.888,9.634 25.884,12.642Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M15.14,13.422L14.866,13.823C14.721,14.03 14.533,14.202 14.316,14.33C14.098,14.457 13.856,14.536 13.605,14.562C13.354,14.588 13.101,14.56 12.861,14.48C12.622,14.399 12.403,14.269 12.219,14.097L5.923,8.029C5.84,7.946 5.79,7.837 5.779,7.721C5.768,7.605 5.798,7.489 5.864,7.392C5.93,7.296 6.027,7.225 6.139,7.192C6.251,7.16 6.371,7.167 6.479,7.213L14.421,10.842C14.651,10.95 14.856,11.106 15.02,11.3C15.185,11.495 15.305,11.722 15.374,11.967C15.442,12.212 15.457,12.469 15.416,12.72C15.376,12.971 15.281,13.211 15.14,13.422Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="23dp"
android:viewportWidth="26"
android:viewportHeight="23">
<group>
<clip-path
android:pathData="M0.884,0.79h25v22h-25z"/>
<path
android:pathData="M10.423,12.974C11.61,12.974 12.771,12.616 13.758,11.946C14.745,11.276 15.514,10.324 15.968,9.211C16.422,8.097 16.541,6.871 16.309,5.689C16.077,4.507 15.505,3.421 14.665,2.569C13.826,1.717 12.756,1.137 11.592,0.902C10.427,0.667 9.22,0.789 8.124,1.25C7.027,1.712 6.09,2.494 5.431,3.497C4.772,4.499 4.421,5.678 4.422,6.883C4.425,8.498 5.058,10.046 6.183,11.188C7.308,12.329 8.833,12.971 10.423,12.974ZM10.423,2.406C11.295,2.406 12.148,2.669 12.873,3.161C13.598,3.653 14.163,4.352 14.497,5.17C14.831,5.988 14.918,6.888 14.748,7.757C14.578,8.626 14.158,9.423 13.541,10.05C12.924,10.676 12.139,11.102 11.283,11.275C10.428,11.448 9.541,11.359 8.736,11.02C7.93,10.681 7.241,10.107 6.757,9.371C6.272,8.635 6.014,7.769 6.014,6.883C6.015,5.696 6.48,4.558 7.306,3.719C8.133,2.879 9.254,2.407 10.423,2.406Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M13.694,14.501H7.001C6.196,14.501 5.399,14.662 4.656,14.976C3.913,15.289 3.238,15.748 2.67,16.326C2.102,16.905 1.651,17.591 1.345,18.347C1.038,19.102 0.882,19.912 0.884,20.729V22.345C0.884,22.404 0.895,22.462 0.917,22.517C0.94,22.571 0.972,22.621 1.013,22.662C1.054,22.704 1.103,22.737 1.157,22.76C1.21,22.782 1.268,22.794 1.326,22.794H2.038C2.154,22.793 2.266,22.745 2.348,22.661C2.43,22.577 2.476,22.463 2.476,22.345V20.729C2.476,19.51 2.952,18.341 3.801,17.48C4.65,16.618 5.8,16.134 7.001,16.134H13.694C14.894,16.134 16.045,16.618 16.894,17.48C17.742,18.341 18.219,19.51 18.219,20.729V22.155C18.219,22.273 18.265,22.387 18.347,22.471C18.429,22.555 18.54,22.603 18.657,22.604H19.369C19.486,22.604 19.599,22.556 19.681,22.472C19.764,22.388 19.811,22.274 19.811,22.155V20.729C19.813,19.912 19.656,19.102 19.35,18.347C19.043,17.591 18.593,16.905 18.025,16.326C17.457,15.748 16.782,15.289 16.039,14.976C15.295,14.662 14.499,14.501 13.694,14.501Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M16.504,2.281C16.534,2.32 16.571,2.353 16.615,2.377C16.658,2.401 16.705,2.415 16.754,2.418C17.875,2.487 18.927,2.987 19.697,3.817C20.467,4.647 20.895,5.744 20.895,6.884C20.895,8.024 20.467,9.12 19.697,9.95C18.927,10.78 17.875,11.281 16.754,11.349C16.705,11.351 16.658,11.364 16.614,11.387C16.571,11.41 16.533,11.443 16.504,11.482C16.278,11.79 16.03,12.08 15.764,12.351C15.715,12.398 15.682,12.458 15.667,12.524C15.652,12.589 15.656,12.658 15.679,12.722C15.701,12.785 15.742,12.84 15.795,12.881C15.848,12.921 15.912,12.945 15.978,12.949C16.825,13.028 17.678,12.917 18.478,12.626C19.814,12.15 20.941,11.209 21.66,9.969C22.379,8.73 22.642,7.272 22.404,5.855C22.165,4.437 21.441,3.151 20.358,2.224C19.275,1.297 17.904,0.789 16.488,0.79C16.307,0.79 16.132,0.798 15.962,0.814C15.896,0.819 15.833,0.843 15.781,0.884C15.728,0.925 15.688,0.98 15.666,1.043C15.643,1.107 15.639,1.175 15.655,1.24C15.67,1.306 15.704,1.365 15.752,1.412C16.022,1.683 16.274,1.973 16.504,2.281Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M19.771,14.501H19.456C19.372,14.493 19.287,14.512 19.214,14.557C19.142,14.601 19.084,14.667 19.052,14.747C19.019,14.826 19.012,14.914 19.032,14.998C19.051,15.082 19.097,15.157 19.162,15.212C19.396,15.453 19.615,15.708 19.819,15.976C19.853,16.023 19.897,16.062 19.948,16.09C19.999,16.118 20.055,16.134 20.113,16.138C21.249,16.229 22.31,16.752 23.082,17.604C23.854,18.455 24.28,19.571 24.276,20.729V22.155C24.276,22.274 24.322,22.388 24.405,22.472C24.488,22.556 24.6,22.604 24.718,22.604H25.43C25.546,22.603 25.658,22.555 25.74,22.471C25.822,22.387 25.868,22.273 25.868,22.155V20.729C25.87,19.913 25.715,19.105 25.41,18.351C25.104,17.596 24.656,16.91 24.09,16.332C23.524,15.754 22.851,15.294 22.11,14.98C21.369,14.666 20.574,14.503 19.771,14.501Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.026h25v25h-25z"/>
<path
android:pathData="M13.41,0.026C10.936,0.021 8.517,0.75 6.458,2.12C4.399,3.49 2.793,5.441 1.843,7.724C0.893,10.008 0.641,12.522 1.121,14.949C1.6,17.375 2.789,19.605 4.536,21.356C6.283,23.106 8.51,24.299 10.936,24.784C13.361,25.268 15.876,25.022 18.161,24.077C20.447,23.132 22.401,21.529 23.775,19.473C25.15,17.417 25.884,14.999 25.884,12.526C25.879,9.217 24.564,6.044 22.227,3.701C19.889,1.359 16.719,0.037 13.41,0.026ZM13.41,2.177C15.782,2.177 18.081,2.994 19.922,4.491L5.357,19.038C4.117,17.523 3.333,15.686 3.098,13.742C2.862,11.798 3.184,9.827 4.027,8.059C4.869,6.291 6.197,4.799 7.855,3.758C9.514,2.716 11.434,2.168 13.392,2.177H13.41ZM13.41,22.871C11.037,22.874 8.736,22.056 6.897,20.557L21.44,6.014C22.679,7.529 23.462,9.365 23.697,11.308C23.932,13.251 23.61,15.221 22.768,16.987C21.926,18.754 20.599,20.246 18.942,21.287C17.286,22.329 15.367,22.878 13.41,22.871Z"
android:fillColor="#FF3A3A"/>
</group>
</vector>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="57dp"
android:height="44dp"
android:viewportWidth="57"
android:viewportHeight="44">
<path
android:pathData="M10.066,2.663L51.25,6.085A4,4 62.853,0 1,54.905 10.402L52.546,38.797A4,4 72.977,0 1,48.229 42.452L7.044,39.031A4,4 77.145,0 1,3.389 34.713L5.748,6.318A4,4 50.655,0 1,10.066 2.663z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M5.182,1.414L45.74,1.414A4,4 0,0 1,49.74 5.414L49.74,33.907A4,4 0,0 1,45.74 37.907L5.182,37.907A4,4 0,0 1,1.182 33.907L1.182,5.414A4,4 0,0 1,5.182 1.414z"
android:strokeWidth="2"
android:strokeColor="#000000"/>
<path
android:pathData="M14.528,20.623L1.708,32.896C1.034,33.542 0.896,34.569 1.376,35.37L1.855,36.168C1.957,36.338 2.083,36.494 2.237,36.619C2.866,37.13 4.009,37.879 4.918,37.879H46.554C46.917,37.879 47.263,37.781 47.574,37.594L48.552,37.007C49.154,36.646 49.523,35.995 49.523,35.292V24.57C49.523,23.99 49.272,23.439 48.834,23.059L38.721,14.277C37.899,13.563 36.656,13.638 35.926,14.446L26.337,25.058C25.646,25.822 24.489,25.937 23.662,25.324L17.102,20.461C16.319,19.88 15.231,19.949 14.528,20.623Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="26dp"
android:viewportWidth="24"
android:viewportHeight="26">
<path
android:pathData="M21.199,25.856C20.633,25.854 20.082,25.679 19.629,25.355L13.501,21.041C12.804,20.549 11.96,20.284 11.093,20.282H4.146C3.292,20.281 2.474,19.957 1.87,19.381C1.266,18.805 0.927,18.024 0.925,17.209V9.481C0.927,8.666 1.266,7.885 1.87,7.309C2.474,6.733 3.292,6.408 4.146,6.407H11.185C12.052,6.402 12.896,6.133 13.592,5.64L19.61,1.358C20.004,1.077 20.472,0.907 20.961,0.866C21.451,0.824 21.942,0.914 22.382,1.124C22.821,1.334 23.19,1.657 23.448,2.056C23.706,2.455 23.842,2.915 23.842,3.384V23.324C23.841,23.995 23.561,24.638 23.064,25.113C22.566,25.587 21.892,25.854 21.189,25.856H21.199ZM4.146,8.704C3.93,8.704 3.723,8.786 3.571,8.932C3.418,9.077 3.333,9.275 3.333,9.481V17.209C3.333,17.415 3.418,17.612 3.571,17.758C3.723,17.903 3.93,17.985 4.146,17.985H11.103C12.48,17.988 13.821,18.41 14.931,19.189L21.059,23.508C21.094,23.535 21.136,23.552 21.181,23.556C21.225,23.56 21.27,23.551 21.31,23.531C21.351,23.513 21.386,23.484 21.41,23.447C21.434,23.41 21.446,23.367 21.444,23.324V3.384C21.447,3.34 21.435,3.297 21.411,3.26C21.387,3.223 21.352,3.194 21.31,3.177C21.272,3.154 21.229,3.141 21.184,3.141C21.14,3.141 21.096,3.154 21.059,3.177L15.036,7.464C13.922,8.253 12.573,8.681 11.185,8.686L4.146,8.704Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<group>
<clip-path
android:pathData="M0.884,0.422h25v25h-25z"/>
<path
android:pathData="M16.589,8.488V4.609L19.577,1.967C19.673,1.888 19.751,1.79 19.808,1.68C19.864,1.571 19.897,1.451 19.904,1.329C19.909,1.212 19.888,1.094 19.844,0.984C19.8,0.874 19.733,0.774 19.648,0.69C19.563,0.605 19.461,0.538 19.348,0.492C19.235,0.446 19.114,0.422 18.992,0.422H7.605C7.478,0.419 7.351,0.44 7.233,0.485C7.114,0.529 7.006,0.597 6.915,0.683C6.83,0.767 6.763,0.867 6.719,0.976C6.675,1.086 6.654,1.203 6.658,1.32C6.661,1.438 6.689,1.554 6.74,1.66C6.791,1.767 6.864,1.863 6.955,1.942L10.095,4.705V8.526C8.645,8.894 7.363,9.714 6.45,10.858C5.538,12.001 5.045,13.404 5.05,14.845C5.061,15.367 5.284,15.864 5.672,16.228C6.06,16.593 6.582,16.796 7.125,16.794H12.475V24.486C12.47,24.712 12.551,24.931 12.703,25.103C12.855,25.275 13.068,25.387 13.301,25.418C13.427,25.429 13.554,25.415 13.674,25.376C13.794,25.337 13.904,25.275 13.997,25.192C14.091,25.11 14.165,25.01 14.216,24.899C14.267,24.787 14.293,24.667 14.292,24.545V16.806H19.642C20.186,16.809 20.708,16.605 21.097,16.24C21.485,15.874 21.708,15.376 21.717,14.854C21.723,13.396 21.219,11.977 20.286,10.828C19.353,9.678 18.045,8.863 16.572,8.514L16.589,8.488ZM10.641,2.148H15.939C15.996,2.147 16.052,2.164 16.099,2.194C16.146,2.225 16.182,2.269 16.202,2.32C16.223,2.372 16.227,2.428 16.213,2.481C16.199,2.534 16.169,2.582 16.126,2.618L15.069,3.55C14.976,3.635 14.901,3.737 14.85,3.851C14.799,3.964 14.773,4.086 14.773,4.21V8.031C14.773,8.103 14.743,8.172 14.689,8.224C14.636,8.275 14.564,8.304 14.489,8.304H12.204C12.129,8.304 12.057,8.275 12.004,8.224C11.95,8.172 11.92,8.103 11.92,8.031V4.306C11.92,4.182 11.893,4.06 11.842,3.946C11.79,3.833 11.714,3.731 11.619,3.647L10.449,2.618C10.408,2.581 10.38,2.533 10.368,2.48C10.355,2.427 10.36,2.372 10.38,2.321C10.401,2.271 10.437,2.227 10.483,2.196C10.53,2.166 10.584,2.149 10.641,2.148ZM19.673,15.043H7.121C7.054,15.042 6.99,15.016 6.943,14.97C6.895,14.925 6.868,14.863 6.867,14.799C6.874,13.543 7.398,12.34 8.324,11.453C9.251,10.566 10.504,10.068 11.811,10.067H14.982C16.292,10.069 17.547,10.569 18.474,11.458C19.401,12.347 19.925,13.553 19.931,14.812C19.926,14.875 19.897,14.933 19.849,14.976C19.801,15.019 19.738,15.043 19.673,15.043Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="25dp"
android:height="26dp"
android:viewportWidth="25"
android:viewportHeight="26">
<path
android:pathData="M23.648,23.011L17.274,16.545C18.854,14.727 19.699,12.371 19.641,9.948C19.583,7.526 18.627,5.214 16.963,3.475C15.299,1.736 13.05,0.698 10.664,0.568C8.278,0.438 5.932,1.225 4.094,2.773C2.256,4.321 1.061,6.515 0.747,8.918C0.434,11.32 1.025,13.754 2.403,15.734C3.781,17.714 5.844,19.093 8.181,19.597C10.518,20.101 12.956,19.693 15.009,18.453L21.575,25.114C21.853,25.396 22.229,25.554 22.622,25.554C23.015,25.554 23.391,25.396 23.669,25.114C23.947,24.833 24.103,24.451 24.103,24.052C24.103,23.654 23.947,23.272 23.669,22.99L23.648,23.011ZM3.174,10.181C3.173,8.78 3.582,7.411 4.349,6.246C5.115,5.08 6.205,4.172 7.481,3.635C8.756,3.099 10.16,2.958 11.514,3.231C12.869,3.503 14.113,4.178 15.09,5.168C16.067,6.158 16.732,7.42 17.001,8.794C17.271,10.168 17.133,11.592 16.605,12.886C16.077,14.181 15.182,15.287 14.034,16.065C12.886,16.844 11.536,17.259 10.155,17.259C8.305,17.257 6.531,16.51 5.223,15.184C3.914,13.857 3.177,12.058 3.174,10.181Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:background="@drawable/preference_top"
android:paddingTop="@dimen/small_spacing"
android:id="@+id/notifyAll"
style="@style/TextAppearance.Session.ConversationSettings.Option"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/notify_type_all"
android:layout_width="0dp"
android:layout_height="72dp"/>
<View
android:layout_marginTop="@dimen/small_spacing"
app:layout_constraintTop_toTopOf="@+id/notifyAll"
app:layout_constraintBottom_toBottomOf="@+id/notifyAll"
app:layout_constraintEnd_toEndOf="@+id/notifyAll"
android:layout_marginEnd="54dp"
android:id="@+id/notifyAllButton"
android:padding="@dimen/small_spacing"
android:layout_width="@dimen/small_radial_size"
android:layout_height="@dimen/small_radial_size"
android:background="@drawable/padded_circle_accent_select"
android:foreground="@drawable/radial_multi_select"/>
<TextView
app:layout_constraintTop_toBottomOf="@+id/notifyAll"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:id="@+id/notifyMentions"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:background="@drawable/preference_middle"
android:text="@string/notify_type_mentions"
android:layout_width="0dp"
android:layout_height="@dimen/setting_button_height"/>
<View
app:layout_constraintTop_toTopOf="@+id/notifyMentions"
app:layout_constraintBottom_toBottomOf="@+id/notifyMentions"
app:layout_constraintEnd_toEndOf="@+id/notifyMentions"
android:layout_marginEnd="54dp"
android:id="@+id/notifyMentionsButton"
android:layout_width="@dimen/small_radial_size"
android:layout_height="@dimen/small_radial_size"
android:background="@drawable/padded_circle_accent_select"
android:foreground="@drawable/radial_multi_select"/>
<TextView
android:paddingBottom="@dimen/small_spacing"
app:layout_constraintTop_toBottomOf="@+id/notifyMentions"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:id="@+id/notifyMute"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:background="@drawable/preference_bottom"
android:text="@string/notify_type_mute"
android:layout_width="0dp"
android:layout_height="72dp"/>
<View
android:layout_marginBottom="@dimen/small_spacing"
app:layout_constraintTop_toTopOf="@+id/notifyMute"
app:layout_constraintBottom_toBottomOf="@+id/notifyMute"
app:layout_constraintEnd_toEndOf="@+id/notifyMute"
android:layout_marginEnd="54dp"
android:id="@+id/notifyMuteButton"
android:layout_width="@dimen/small_radial_size"
android:layout_height="@dimen/small_radial_size"
android:background="@drawable/padded_circle_accent_select"
android:foreground="@drawable/radial_multi_select"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,382 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivity"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/back"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:src="@drawable/ic_baseline_arrow_back_24"
android:scaleType="centerInside"
android:layout_width="?android:actionBarSize"
android:layout_height="?android:actionBarSize"
app:tint="?android:textColorPrimary" />
<include
android:id="@+id/profilePictureView"
layout="@layout/view_large_profile_picture"
android:layout_height="120dp"
android:layout_width="120dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="@dimen/small_profile_picture_size"
/>
<TextView
android:id="@+id/conversationName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/massive_spacing"
android:layout_marginTop="@dimen/small_spacing"
style="@style/TextAppearance.Session.ConversationSettings.Title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profilePictureView"
tools:text="@tools:sample/full_names" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/conversationSubtitle"
app:layout_constraintTop_toBottomOf="@id/conversationName"
android:layout_marginHorizontal="@dimen/massive_spacing"
style="@style/TextAppearance.Session.ConversationSettings.Subtitle"
tools:text="@tools:sample/lorem/random"
android:maxLines="2"
android:ellipsize="end"
/>
<!-- Main conversation settings -->
<LinearLayout
android:id="@+id/mainConversationSettingContainer"
android:background="@drawable/preference_single_no_padding"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/conversationSubtitle"
android:layout_marginTop="@dimen/large_spacing"
android:layout_marginHorizontal="@dimen/very_large_spacing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:background="@drawable/debug_border"
android:orientation="vertical">
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_search_conversation"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:text="@string/conversation_settings_search"
android:id="@+id/searchConversation"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_edit_group"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:text="@string/conversation_settings_group_members"
android:id="@+id/groupMembers"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:id="@+id/groupMembersDivider"
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_all_media"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:text="@string/conversation_settings_all_media"
android:id="@+id/allMedia"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_pin_conversation"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:text="@string/conversation_settings_pin_conversation"
android:id="@+id/pinConversation"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<LinearLayout
android:id="@+id/notificationSettings"
android:background="?selectableItemBackground"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"
android:paddingHorizontal="@dimen/very_large_spacing"
android:orientation="horizontal">
<ImageView
android:src="@drawable/ic_notification_settings"
android:layout_gravity="center"
android:layout_width="@dimen/setting_image_size"
android:layout_height="@dimen/setting_image_size"
app:tint="?android:textColorPrimary" />
<LinearLayout
android:layout_marginStart="@dimen/very_large_spacing"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conversation_settings_notifications"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:paddingVertical="1dp"
style="@style/TextAppearance.Session.ConversationSettings.Option"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/notificationsValue"
tools:text="@tools:sample/lorem"
android:paddingVertical="1dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
style="@style/TextAppearance.Session.ConversationSettings.OptionSummary"/>
</LinearLayout>
</LinearLayout>
<include
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/switchContainer"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_width="96dp"
android:layout_height="match_parent">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/autoDownloadMediaSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:minHeight="48dp" />
</FrameLayout>
<LinearLayout
android:id="@+id/autoDownloadMediaContainer"
android:layout_toEndOf="@+id/switchContainer"
android:layout_alignParentEnd="true"
android:layout_marginEnd="@dimen/very_large_spacing"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
android:layout_width="wrap_content">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conversation_settings_auto_download_title"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:paddingVertical="1dp"
style="@style/TextAppearance.Session.ConversationSettings.Option"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conversation_settings_auto_download_summary"
android:paddingVertical="1dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
style="@style/TextAppearance.Session.ConversationSettings.OptionSummary"/>
</LinearLayout>
</RelativeLayout>
</LinearLayout>
<!-- Admin settings -->
<androidx.constraintlayout.widget.Group
android:id="@+id/adminControlsGroup"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="adminContainer,adminSettingsTitle"/>
<TextView
style="@style/TextAppearance.Session.ConversationSettings.Subtitle"
android:text="@string/conversation_settings_admin_settings_title"
android:id="@+id/adminSettingsTitle"
app:layout_constraintTop_toBottomOf="@+id/mainConversationSettingContainer"
android:layout_marginTop="@dimen/medium_spacing"
app:layout_constraintStart_toStartOf="@+id/adminContainer"
app:layout_constraintEnd_toEndOf="@+id/adminContainer"
app:layout_constraintHorizontal_bias="0"
android:layout_marginHorizontal="@dimen/large_spacing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/adminContainer"
android:background="@drawable/preference_single_no_padding"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/adminSettingsTitle"
android:layout_marginTop="@dimen/small_spacing"
android:layout_marginHorizontal="@dimen/very_large_spacing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:background="@drawable/debug_border"
android:orientation="vertical">
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_edit_group"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:text="@string/conversation_settings_edit_group"
android:id="@+id/editGroup"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:id="@+id/editGroupDivider"
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_add_admins"
style="@style/TextAppearance.Session.ConversationSettings.Option"
android:text="@string/conversation_settings_add_admins"
android:id="@+id/addAdmins"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<LinearLayout
android:background="?selectableItemBackground"
android:id="@+id/disappearingMessages"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"
android:paddingHorizontal="@dimen/very_large_spacing"
android:orientation="horizontal">
<ImageView
android:src="@drawable/ic_disappearing_messages"
android:layout_gravity="center"
android:layout_width="@dimen/setting_image_size"
android:layout_height="@dimen/setting_image_size"
app:tint="?android:textColorPrimary" />
<LinearLayout
android:layout_marginStart="@dimen/very_large_spacing"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conversation_settings_disappearing_messages"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:paddingVertical="1dp"
style="@style/TextAppearance.Session.ConversationSettings.Option"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/disappearingMessagesValue"
tools:text="@tools:sample/lorem"
android:paddingVertical="1dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
style="@style/TextAppearance.Session.ConversationSettings.OptionSummary"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/destructiveContainer"
android:background="@drawable/preference_single_no_padding"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/adminContainer"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginTop="@dimen/medium_spacing"
android:layout_marginBottom="@dimen/massive_spacing"
android:layout_marginHorizontal="@dimen/very_large_spacing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:background="@drawable/debug_border"
android:orientation="vertical">
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_clear_messages"
style="@style/TextAppearance.Session.ConversationSettings.Option.Destructive"
android:text="@string/conversation_settings_clear_messages"
android:id="@+id/clearMessages"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:id="@+id/clearMessagesDivider"
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_leave_group"
style="@style/TextAppearance.Session.ConversationSettings.Option.Destructive"
android:text="@string/conversation_settings_leave_group"
android:id="@+id/leaveGroup"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
<include
android:id="@+id/leaveGroupDivider"
android:layout_marginHorizontal="@dimen/large_spacing"
layout="@layout/preference_divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:background="?selectableItemBackground"
app:drawableStartCompat="@drawable/ic_delete"
style="@style/TextAppearance.Session.ConversationSettings.Option.Destructive"
android:text="@string/conversation_settings_delete_group"
android:id="@+id/deleteGroup"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_button_height"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@ -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">
<LinearLayout
android:id="@+id/mainContentContainer"

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