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 { plugins {
id 'kotlin-kapt' id 'kotlin-kapt'
id 'com.google.dagger.hilt.android' id 'com.google.dagger.hilt.android'
id 'com.google.devtools.ksp'
id 'app.cash.molecule'
} }
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'witness' apply plugin: 'witness'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlinx-serialization' apply plugin: 'kotlinx-serialization'
apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'dagger.hilt.android.plugin'
@ -47,12 +48,12 @@ android {
useLibrary 'org.apache.http.legacy' useLibrary 'org.apache.http.legacy'
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_11
} }
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '11'
} }
packagingOptions { packagingOptions {
@ -78,7 +79,7 @@ android {
compose true compose true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion '1.4.7' kotlinCompilerExtensionVersion '1.5.3'
} }
defaultConfig { defaultConfig {
@ -205,8 +206,14 @@ android {
dependencies { dependencies {
implementation("com.google.dagger:hilt-android:2.46.1") implementation("com.google.dagger:hilt-android:$daggerVersion")
kapt("com.google.dagger:hilt-android-compiler:2.44") 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.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
@ -299,9 +306,9 @@ dependencies {
implementation "com.opencsv:opencsv:4.6" implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.10.0" testImplementation "org.mockito:mockito-inline:4.11.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" 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" androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.2.0" testImplementation "androidx.arch.core:core-testing:2.2.0"
@ -313,7 +320,6 @@ dependencies {
androidTestImplementation('com.adevinta.android:barista:4.2.0') { androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin' exclude group: 'org.jetbrains.kotlin'
} }
// AndroidJUnitRunner and JUnit Rules // AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' 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:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent: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.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' androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation 'androidx.compose.ui:ui:1.5.2' implementation 'androidx.compose.ui:ui:1.5.3'
implementation 'androidx.compose.ui:ui-tooling:1.5.2' 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-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators: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.foundation:foundation-layout:1.5.3'
implementation 'androidx.compose.material:material:1.5.2' implementation 'androidx.compose.material:material:1.5.3'
} }
static def getLastCommitTimestamp() { static def getLastCommitTimestamp() {

View File

@ -1,8 +1,10 @@
<manifest <manifest
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android">
package="network.loki.messenger.test">
<application> <application>
<uses-library android:name="android.test.runner" <uses-library android:name="android.test.runner"
android:required="false" /> android:required="false" />
<activity android:name="androidx.activity.ComponentActivity"/>
</application> </application>
</manifest> </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 package network.loki.messenger
import android.Manifest
import android.app.Instrumentation import android.app.Instrumentation
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.view.View
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack 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.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed 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.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.adevinta.android.barista.interaction.PermissionGranter import network.loki.messenger.util.sendMessage
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.setupLoggedInState
import org.hamcrest.Matcher import network.loki.messenger.util.waitFor
import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.not import org.hamcrest.Matchers.not
import org.junit.After 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.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 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.home.HomeActivity
import org.thoughtcrime.securesms.mms.GlideApp
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @SmallTest
class HomeActivityTests { class HomeActivityTests {
@get:Rule @get:Rule
@ -59,38 +53,6 @@ class HomeActivityTests {
InstrumentationRegistry.getInstrumentation().removeMonitor(activityMonitor) 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() { private fun goToMyChat() {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
@ -134,11 +96,13 @@ class HomeActivityTests {
setupLoggedInState() setupLoggedInState()
goToMyChat() goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
sendMessage("howdy") with (activityMonitor.waitForActivity() as ConversationActivityV2) {
sendMessage("test") sendMessage("howdy")
// tests url rewriter doesn't crash sendMessage("test")
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest") // tests url rewriter doesn't crash
sendMessage("https://www.ámazon.com") sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
sendMessage("https://www.ámazon.com")
}
} }
@Test @Test
@ -148,7 +112,9 @@ class HomeActivityTests {
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
// given the link url text // given the link url text
val url = "https://www.ámazon.com" 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 // when the URL span is clicked
onView(withSubstring(url)).perform(ViewActions.click()) onView(withSubstring(url)).perform(ViewActions.click())
@ -162,21 +128,4 @@ class HomeActivityTests {
onView(withText(dialogPromptText)).check(matches(isDisplayed())) 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.Contacts
import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode 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.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.argThat import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -22,32 +25,15 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@SmallTest @SmallTest
class LibSessionTests { 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 var fakeHashI = 0
private val nextFakeHash: String private val nextFakeHash: String
get() = "fakehash${fakeHashI++}" 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 { private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!! val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.Companion.newInstance(key) val contacts = Contacts.Companion.newInstance(key)
@ -80,9 +66,8 @@ class LibSessionTests {
@Test @Test
fun migration_one_to_ones() { fun migration_one_to_ones() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage) val storage = applicationContext.applySpiedStorage()
app.storage = storageSpy
val newContactId = randomSessionId() val newContactId = randomSessionId()
val singleContact = Contact( val singleContact = Contact(
@ -93,10 +78,10 @@ class LibSessionTests {
val newContactMerge = buildContactMessage(listOf(singleContact)) val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge) fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat { verify(storage).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1 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:theme="@style/Theme.Session.DayNight.FlatActionBar"
android:label="@string/blocked_contacts_title" 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 <activity
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity" android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"
android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:label="@string/activity_edit_closed_group_title" android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity <activity
@ -232,6 +237,13 @@
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" /> android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity> </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 <activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity" android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"

View File

@ -32,11 +32,12 @@ import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner;
import org.conscrypt.Conscrypt; import org.conscrypt.Conscrypt;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.avatars.AvatarHelper; import org.session.libsession.avatars.AvatarHelper;
import org.session.libsession.database.MessageDataProvider; import org.session.libsession.database.MessageDataProvider;
import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; 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.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule; import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
@ -65,6 +66,7 @@ import org.thoughtcrime.securesms.dependencies.AppComponent;
import org.thoughtcrime.securesms.dependencies.ConfigFactory; import org.thoughtcrime.securesms.dependencies.ConfigFactory;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.dependencies.PollerFactory;
import org.thoughtcrime.securesms.emoji.EmojiSource; import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.groups.OpenGroupManager; import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.home.HomeActivity;
@ -108,9 +110,8 @@ import javax.inject.Inject;
import dagger.hilt.EntryPoints; import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp; import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit; import kotlin.Unit;
import kotlinx.coroutines.Job;
import network.loki.messenger.BuildConfig; 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; import network.loki.messenger.libsession_util.UserProfile;
/** /**
@ -136,7 +137,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public MessageNotifier messageNotifier = null; public MessageNotifier messageNotifier = null;
public Poller poller = null; public Poller poller = null;
public Broadcaster broadcaster = null; public Broadcaster broadcaster = null;
private Job firebaseInstanceIdJob;
private WindowDebouncer conversationListDebouncer; private WindowDebouncer conversationListDebouncer;
private HandlerThread conversationListHandlerThread; private HandlerThread conversationListHandlerThread;
private Handler conversationListHandler; private Handler conversationListHandler;
@ -149,6 +149,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject TextSecurePreferences textSecurePreferences; @Inject TextSecurePreferences textSecurePreferences;
@Inject PushRegistry pushRegistry; @Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory; @Inject ConfigFactory configFactory;
@Inject PollerFactory pollerFactory;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@ -197,7 +198,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
@Override @Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) { public void notifyUpdates(@NotNull Config forConfigObject) {
// forward to the config factory / storage ig // forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true); textSecurePreferences.setConfigurationMessageSynced(true);
@ -219,7 +220,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
messageDataProvider, messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory configFactory
); );
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); Log.i(TAG, "onCreate()");
startKovenant(); startKovenant();
@ -282,13 +283,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) { if (poller != null) {
poller.stopIfNeeded(); poller.stopIfNeeded();
} }
ClosedGroupPollerV2.getShared().stopAll(); pollerFactory.stopAll();
LegacyClosedGroupPollerV2.getShared().stopAll();
} }
@Override @Override
public void onTerminate() { public void onTerminate() {
stopKovenant(); // Loki stopKovenant(); // Loki
OpenGroupManager.INSTANCE.stopPolling(); OpenGroupManager.INSTANCE.stopPolling();
pollerFactory.stopAll();
super.onTerminate(); super.onTerminate();
} }
@ -437,7 +440,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.setUserPublicKey(userPublicKey); poller.setUserPublicKey(userPublicKey);
return; return;
} }
poller = new Poller(configFactory, new Timer()); poller = new Poller(configFactory);
} }
public void startPollingIfNeeded() { public void startPollingIfNeeded() {
@ -445,7 +448,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) { if (poller != null) {
poller.startIfNeeded(); poller.startIfNeeded();
} }
ClosedGroupPollerV2.getShared().start(); pollerFactory.startAll();
LegacyClosedGroupPollerV2.getShared().start();
} }
private void resubmitProfilePictureIfNeeded() { private void resubmitProfilePictureIfNeeded() {
@ -500,9 +504,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
public void clearAllData(boolean isMigratingToV2KeyPair) { public void clearAllData(boolean isMigratingToV2KeyPair) {
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this); String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this); boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(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.Context;
import android.content.Intent; import android.content.Intent;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.content.res.Resources;
import android.database.Cursor; import android.database.Cursor;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
@ -35,7 +34,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode; import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
@ -45,32 +43,32 @@ import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.loader.app.LoaderManager; import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader; import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager; import androidx.viewpager.widget.ViewPager;
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
import com.google.android.material.tabs.TabLayout; 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.messages.control.DataExtractionNotification;
import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI; import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import org.session.libsession.utilities.GroupRecord;
import org.thoughtcrime.securesms.database.MediaDatabase; import org.session.libsession.utilities.TextSecurePreferences;
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.Util; import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.ViewUtil; import org.session.libsession.utilities.ViewUtil;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.task.ProgressDialogAsyncTask; 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.Collection;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -82,423 +80,450 @@ import network.loki.messenger.R;
/** /**
* Activity for displaying media attachments in-app * Activity for displaying media attachments in-app
*/ */
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements View.OnClickListener {
@SuppressWarnings("unused") @SuppressWarnings("unused")
private final static String TAG = MediaOverviewActivity.class.getSimpleName(); 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> {
public static final String ADDRESS_EXTRA = "address"; public static final String ADDRESS_EXTRA = "address";
public static final String LOCALE_EXTRA = "locale_extra";
protected TextView noMedia; private Toolbar toolbar;
protected Recipient recipient; private TabLayout tabLayout;
protected RecyclerView recyclerView; private ViewPager viewPager;
protected Locale locale; private Recipient recipient;
@Override @Override
public void onCreate(Bundle bundle) { protected void onCreate(Bundle bundle, boolean ready) {
super.onCreate(bundle); setContentView(R.layout.media_overview_activity);
String address = getArguments().getString(ADDRESS_EXTRA); initializeResources();
Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA); initializeToolbar();
if (address == null) throw new AssertionError(); this.tabLayout.setupWithViewPager(viewPager);
if (locale == null) throw new AssertionError(); this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
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;
} }
@Override @Override
public void onConfigurationChanged(Configuration newConfig) { public boolean onOptionsItemSelected(MenuItem item) {
super.onConfigurationChanged(newConfig); super.onOptionsItemSelected(item);
if (gridManager != null) {
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); switch (item.getItemId()) {
this.recyclerView.setLayoutManager(gridManager); 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 @Override
public @NonNull Loader<BucketedThreadMedia> onCreateLoader(int i, Bundle bundle) { public void onClick(View v) {
return new BucketedThreadMediaLoader(getContext(), recipient.getAddress()); if (v.getId() == R.id.clearMedia) {
// TODO: future chunk
}
} }
@Override public void onExitMultiSelect() {
public void onLoadFinished(@NonNull Loader<BucketedThreadMedia> loader, BucketedThreadMedia bucketedThreadMedia) { tabLayout.setEnabled(true);
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia); viewPager.setEnabled(true);
((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
getActivity().invalidateOptionsMenu();
} }
@Override private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
public void onLoaderReset(@NonNull Loader<BucketedThreadMedia> cursorLoader) {
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext()));
}
@Override MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) { super(fragmentManager);
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 @Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) { public Fragment getItem(int position) {
if (records == null || records.length == 0) { Fragment fragment;
return null;
}
for (MediaDatabase.MediaRecord record : records) { if (position == 0) fragment = new MediaOverviewGalleryFragment();
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
} else throw new AssertionError();
return null;
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() { @Override
getListAdapter().selectAllMedia(); public int getCount() {
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount())); return 2;
}
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 @Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) { public CharSequence getPageTitle(int position) {
return false; if (position == 0) return getString(R.string.MediaOverviewActivity_Media);
} else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
else throw new AssertionError();
@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 static abstract class MediaOverviewFragment<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
public void onDestroyActionMode(ActionMode mode) {
actionMode = null;
getListAdapter().clearSelection();
((MediaOverviewActivity) getActivity()).onExitMultiSelect();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { public static final String ADDRESS_EXTRA = "address";
getActivity().getWindow().setStatusBarColor(originalStatusBarColor); 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 static class MediaOverviewGalleryFragment
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) { extends MediaOverviewFragment<Cursor>
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false); 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 static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
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 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 this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
public void onLoaderReset(@NonNull Loader<Cursor> loader) { this.noMedia = ViewUtil.findById(view, R.id.no_documents);
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
getActivity().invalidateOptionsMenu(); 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 return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
} }
if (recipient.isClosedGroupRecipient) { if (recipient.isLegacyClosedGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase() val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(recipient.address.toGroupString(), true) .getGroupMemberAddresses(recipient.address.toGroupString(), true)
.sorted() .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> { private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
return getItems(contacts, context.getString(R.string.fragment_contact_selection_closed_groups_title)) { 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.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob 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.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel 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.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized 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.IdPrefix
import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.SessionId
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
@ -105,6 +106,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey 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.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP 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.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
@ -198,7 +200,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
ConversationMenuHelper.ConversationMenuListener { ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
private var binding: ActivityConversationV2Binding? = null private var binding: ActivityConversationV2Binding? = null
@ -213,7 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase
@Inject lateinit var storage: Storage @Inject lateinit var storage: StorageProtocol
@Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @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 screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val linkPreviewViewModel: LinkPreviewViewModel by lazy { private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
@ -238,7 +247,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val sessionId = SessionId(it.serialize()) val sessionId = SessionId(it.serialize())
val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1))
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { 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) fromSerialized(it)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId) } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
} else { } else {
@ -361,7 +370,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_GIF = 10 const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12 const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124 const val INVITE_CONTACTS = 124
const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result
} }
// endregion // endregion
@ -414,6 +423,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updatePlaceholder() updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding!!.searchBottomBar.setEventListener(this)
binding!!.toolbarContent.profilePictureView.setOnClickListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded() showOrHideInputIfNeeded()
setUpMessageRequestsBar() setUpMessageRequestsBar()
@ -578,7 +588,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
recipient.isLocalNumber -> getString(R.string.note_to_self) recipient.isLocalNumber -> getString(R.string.note_to_self)
else -> recipient.toShortString() 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 R.dimen.medium_profile_picture_size
} else { } else {
R.dimen.small_profile_picture_size R.dimen.small_profile_picture_size
@ -810,8 +820,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun showOrHideInputIfNeeded() { private fun showOrHideInputIfNeeded() {
val recipient = viewModel.recipient val recipient = viewModel.recipient ?: return
if (recipient != null && recipient.isClosedGroupRecipient) { if (recipient.isLegacyClosedGroupRecipient) {
val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() val group = groupDb.getGroup(recipient.address.toGroupString()).orNull()
val isActive = (group?.isActive == true) val isActive = (group?.isActive == true)
binding?.inputBar?.showInput = isActive binding?.inputBar?.showInput = isActive
@ -821,8 +831,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun setUpMessageRequestsBar() { private fun setUpMessageRequestsBar() {
val recipient = viewModel.recipient ?: return
binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread() binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread()
binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread() 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 { binding?.acceptMessageRequestButton?.setOnClickListener {
acceptMessageRequest() acceptMessageRequest()
} }
@ -856,11 +871,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun isIncomingMessageRequestThread(): Boolean { private fun isIncomingMessageRequestThread(): Boolean {
val recipient = viewModel.recipient ?: return false val recipient = viewModel.recipient ?: return false
return !recipient.isGroupRecipient && return !recipient.isLegacyClosedGroupRecipient &&
!recipient.isOpenGroupRecipient &&
!recipient.isApproved && !recipient.isApproved &&
!recipient.isLocalNumber && !recipient.isLocalNumber &&
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() &&
threadDb.getMessageCount(viewModel.threadId) > 0 (threadDb.getMessageCount(viewModel.threadId) > 0 || recipient.isClosedGroupRecipient)
} }
override fun inputBarEditTextContentChanged(newContent: CharSequence) { override fun inputBarEditTextContentChanged(newContent: CharSequence) {
@ -1116,14 +1132,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
} }
} else if (recipient.isGroupRecipient) { } else if (recipient.isGroupRecipient) {
viewModel.openGroup?.let { openGroup -> when {
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 recipient.isOpenGroupRecipient -> {
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount) viewModel.openGroup?.let { openGroup ->
} ?: run { val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_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 { } else {
actionBarBinding.conversationSubtitleView.isVisible = false actionBarBinding.conversationSubtitleView.isVisible = false
} }
@ -1140,6 +1164,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} ?: false } ?: false
} }
override fun onClick(v: View?) {
if (v === binding?.toolbarContent?.profilePictureView) {
// open conversation settings
conversationSettingsCallback.launch(viewModel.threadId)
}
}
override fun block(deleteThread: Boolean) { override fun block(deleteThread: Boolean) {
showSessionDialog { showSessionDialog {
title(R.string.RecipientPreferenceActivity_block_this_contact_question) 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() 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) { override fun showExpiringMessagesDialog(thread: Recipient) {
if (thread.isClosedGroupRecipient) { if (thread.isLegacyClosedGroupRecipient) {
val group = groupDb.getGroup(thread.address.toGroupString()).orNull() val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
if (group?.isActive == false) { return } 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.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi 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.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.Storage import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
@ -28,7 +29,7 @@ class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: StorageProtocol
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
@ -58,19 +59,27 @@ class ConversationViewModel(
val openGroup: OpenGroup? val openGroup: OpenGroup?
get() = _openGroup.value 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> val serverCapabilities: List<String>
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf() get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
val blindedPublicKey: String? val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString()
} }
val isMessageRequestThread : Boolean val isMessageRequestThread : Boolean
get() { get() {
val recipient = recipient ?: return false val recipient = recipient ?: return false
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isOpenGroupRecipient && !recipient.isApproved
} }
val canReactToMessages: Boolean val canReactToMessages: Boolean
@ -186,7 +195,8 @@ class ConversationViewModel(
} }
fun declineMessageRequest() { fun declineMessageRequest() {
repository.declineMessageRequest(threadId) val recipient = recipient ?: return
repository.declineMessageRequest(threadId, recipient)
} }
private fun showMessage(message: String) { private fun showMessage(message: String) {
@ -228,7 +238,7 @@ class ConversationViewModel(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: StorageProtocol
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {

View File

@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
if (!this::recipient.isInitialized) { if (!this::recipient.isInitialized) {
return dismiss() 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 = binding.deleteForEveryoneTextView.text =
resources.getString(R.string.delete_message_for_me_and_recipient, contact) 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.deleteForMeTextView.setOnClickListener(this)
binding.deleteForEveryoneTextView.setOnClickListener(this) binding.deleteForEveryoneTextView.setOnClickListener(this)
binding.cancelTextView.setOnClickListener(this) binding.cancelTextView.setOnClickListener(this)

View File

@ -55,6 +55,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@ -81,7 +82,7 @@ import javax.inject.Inject
class MessageDetailActivity : PassphraseRequiredActionBarActivity() { class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Inject @Inject
lateinit var storage: Storage lateinit var storage: StorageProtocol
private val viewModel: MessageDetailsViewModel by viewModels() private val viewModel: MessageDetailsViewModel by viewModels()

View File

@ -9,44 +9,53 @@ import android.text.style.StyleSpan
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject import javax.inject.Inject
/** Shown when receiving media from a contact for the first time, to confirm that /** 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. */ * they are to be trusted and files sent by them are to be downloaded. */
@AndroidEntryPoint @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 @Inject lateinit var contactDB: SessionContactDatabase
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val sessionID = recipient.address.toString() val threadId = storage.getThreadId(threadRecipient) ?: run {
val contact = contactDB.getContactWithSessionID(sessionID) dismiss()
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID return@createSessionDialog
title(resources.getString(R.string.dialog_download_title, name)) }
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 spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name) val startIndex = explanation.indexOf(displayName)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text(spannable) text(spannable)
button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() } button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) {
cancelButton { dismiss() } setAutoDownload(true)
}
cancelButton {
setAutoDownload(false)
}
} }
private fun trust() { private fun setAutoDownload(shouldDownload: Boolean) {
val sessionID = recipient.address.toString() storage.setAutoDownloadAttachments(threadRecipient, shouldDownload)
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()
} }
} }

View File

@ -6,10 +6,10 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } 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 { fun userCanDeleteSelectedItems(): Boolean {
val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
val allReceivedByCurrentUser = 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.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
@ -63,7 +63,7 @@ object ConversationMenuHelper {
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages // Expiring messages
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { if (!isOpenGroup && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient) && !thread.isBlocked) {
if (thread.expireMessages > 0) { if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu) inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages) val item = menu.findItem(R.id.menu_expiring_messages)
@ -92,7 +92,7 @@ object ConversationMenuHelper {
} }
} }
// Closed group menu (options that should only be present in closed groups) // 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) inflater.inflate(R.menu.menu_conversation_closed_group, menu)
} }
// Open group menu // Open group menu
@ -280,15 +280,15 @@ object ConversationMenuHelper {
} }
private fun editClosedGroup(context: Context, thread: Recipient) { private fun editClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return } if (!thread.isLegacyClosedGroupRecipient) { return }
val intent = Intent(context, EditClosedGroupActivity::class.java) val intent = Intent(context, EditLegacyClosedGroupActivity::class.java)
val groupID: String = thread.address.toGroupString() val groupID: String = thread.address.toGroupString()
intent.putExtra(groupIDKey, groupID) intent.putExtra(groupIDKey, groupID)
context.startActivity(intent) context.startActivity(intent)
} }
private fun leaveClosedGroup(context: Context, thread: Recipient) { 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 group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins 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), glide: GlideRequests = GlideApp.with(this),
thread: Recipient, thread: Recipient,
searchQuery: String? = null, searchQuery: String? = null,
contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (Long, Long) -> Unit, onAttachmentNeedsDownload: (Long, Long) -> Unit,
suppressThumbnails: Boolean = false suppressThumbnails: Boolean = false
) { ) {
@ -74,8 +73,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.mainColor = color binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
val onlyBodyMessage = message is SmsMessageRecord val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
// reset visibilities / containers // reset visibilities / containers
onContentClick.clear() onContentClick.clear()
@ -88,7 +88,6 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.isVisible = false binding.bodyTextView.isVisible = false
binding.quoteView.root.isVisible = false binding.quoteView.root.isVisible = false
binding.linkPreviewView.root.isVisible = false binding.linkPreviewView.root.isVisible = false
binding.untrustedView.root.isVisible = false
binding.voiceMessageView.root.isVisible = false binding.voiceMessageView.root.isVisible = false
binding.documentView.root.isVisible = false binding.documentView.root.isVisible = false
binding.albumThumbnailView.root.isVisible = false binding.albumThumbnailView.root.isVisible = false
@ -103,9 +102,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.text = null binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() 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.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
@ -151,6 +150,7 @@ class VisibleMessageContentView : ConstraintLayout {
} }
when { when {
// LINK PREVIEW
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } 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 // When in a link preview ensure the bodyTextView can expand to the full width
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
} }
// AUDIO
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
hideBody = true hideBody = true
// Audio attachment // Audio attachment
if (contactIsTrusted || message.isOutgoing) { if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.voiceMessageView.root.indexInAdapter = indexInAdapter binding.voiceMessageView.root.indexInAdapter = indexInAdapter
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
@ -170,26 +171,38 @@ class VisibleMessageContentView : ConstraintLayout {
onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
} else { } else {
// TODO: move this out to its own area hideBody = true
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } 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 -> { message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
hideBody = true hideBody = true // TODO: check if this is still the logic we want
// Document attachment // Document attachment
if (contactIsTrusted || message.isOutgoing) { if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) binding.documentView.root.bind(message, getTextColor(context, message))
} else { } else {
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) hideBody = true
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } (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() -> { message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
/* if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
* Images / Video attachment
*/
if (contactIsTrusted || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // 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 // bind after add view because views are inflated and calculated during bind
binding.albumThumbnailView.root.bind( binding.albumThumbnailView.root.bind(
@ -207,13 +220,22 @@ class VisibleMessageContentView : ConstraintLayout {
} else { } else {
hideBody = true hideBody = true
binding.albumThumbnailView.root.clearViews() binding.albumThumbnailView.root.clearViews()
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } firstAttachment?.let { attachment ->
binding.pendingAttachmentView.root.bind(
PendingAttachmentView.AttachmentType.IMAGE,
getTextColor(context,message),
attachment
)
onContentClick.add {
binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment)
}
}
} }
} }
message.isOpenGroupInvitation -> { message.isOpenGroupInvitation -> {
hideBody = true 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() } onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
} }
} }
@ -250,7 +272,7 @@ class VisibleMessageContentView : ConstraintLayout {
fun recycle() { fun recycle() {
arrayOf( arrayOf(
binding.deletedMessageView.root, binding.deletedMessageView.root,
binding.untrustedView.root, binding.pendingAttachmentView.root,
binding.voiceMessageView.root, binding.voiceMessageView.root,
binding.openGroupInvitationView.root, binding.openGroupInvitationView.root,
binding.documentView.root, binding.documentView.root,

View File

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

View File

@ -11,23 +11,31 @@ object MentionManagerUtilities {
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) { fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
val result = mutableSetOf<String>() val result = mutableSetOf<String>()
val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return
if (recipient.address.isClosedGroup) { val storage = DatabaseComponent.get(context).storage()
val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() } when {
result.addAll(members) recipient.address.isLegacyClosedGroup -> {
} else { val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase() result.addAll(members)
val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200)) }
var record: MessageRecord? = reader.next recipient.address.isClosedGroup -> {
while (record != null) { val members = storage.getMembers(recipient.address.serialize())
result.add(record.individualRecipient.address.serialize()) result.addAll(members.map { it.sessionId })
try { }
record = reader.next recipient.address.isOpenGroup -> {
} catch (exception: Exception) { val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
record = null 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 MentionsManager.userPublicKeyCache[threadID] = result
} }

View File

@ -966,10 +966,6 @@ public class AttachmentDatabase extends Database {
@SuppressLint("NewApi") @SuppressLint("NewApi")
private ThumbnailData generateVideoThumbnail(AttachmentId attachmentId) { 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); DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA);

View File

@ -4,6 +4,9 @@ import android.content.Context
import androidx.core.content.contentValuesOf import androidx.core.content.contentValuesOf
import androidx.core.database.getBlobOrNull import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull 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 import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { 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));" "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_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) { 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)) 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? { fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
val db = readableDatabase val db = readableDatabase
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) 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.MESSAGE_BOX + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " + 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 + "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.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 " + " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
+ AttachmentDatabase.DATA + " IS NOT NULL AND " + AttachmentDatabase.DATA + " IS NOT NULL AND "
+ AttachmentDatabase.QUOTE + " = 0 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"; + "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/%'"); 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 * Delete all the messages in single queries where possible
* @param messageIds a String array representation of regularly Long types representing message IDs * @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)) 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( private fun getSerializedSharedContacts(
insertedAttachmentIds: Map<Attachment?, AttachmentId?>, insertedAttachmentIds: Map<Attachment?, AttachmentId?>,
contacts: List<Contact?> contacts: List<Contact?>
@ -1069,7 +1108,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false return false
} }
/*package*/
private fun deleteThreads(threadIds: Set<Long>) { private fun deleteThreads(threadIds: Set<Long>) {
val db = databaseHelper.writableDatabase val db = databaseHelper.writableDatabase
val where = StringBuilder() 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 NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash"; private static final String WRAPPER_HASH = "wrapper_hash";
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests"; 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[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH,
FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD,
}; };
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) 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;"; "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() { public static String getCreateApprovedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " + return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;"; "ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
@ -178,30 +190,31 @@ public class RecipientDatabase extends Database {
} }
Optional<RecipientSettings> getRecipientSettings(@NonNull Cursor cursor) { Optional<RecipientSettings> getRecipientSettings(@NonNull Cursor cursor) {
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1; boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE)); int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR)); boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1;
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID)); String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES)); int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED)); int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY)); int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)); String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI)); String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL)); String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI)); String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR)); String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR));
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; 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)); String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1; 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, return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
notifyType, notifyType, autoDownloadAttachments,
Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState), Recipient.VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone), Util.uri(messageRingtone), Util.uri(callRingtone),
@ -238,6 +251,22 @@ public class RecipientDatabase extends Database {
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests)); 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) { public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(COLOR, color.serialize()); values.put(COLOR, color.serialize());
@ -313,6 +342,21 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); 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) { public void setMuted(@NonNull Recipient recipient, long until) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(MUTE_UNTIL, until); values.put(MUTE_UNTIL, until);

View File

@ -3,17 +3,16 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
companion object { companion object {
private const val sessionContactTable = "session_contact_database" const val sessionContactTable = "session_contact_database"
const val sessionID = "session_id" const val sessionID = "session_id"
const val name = "name" const val name = "name"
const val nickname = "nickname" const val nickname = "nickname"
@ -74,23 +73,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it)) contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
} }
contentValues.put(threadID, contact.threadID) contentValues.put(threadID, contact.threadID)
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID )) database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID ))
notifyConversationListListeners() notifyConversationListListeners()
} }
fun contactFromCursor(cursor: Cursor): Contact { fun contactFromCursor(cursor: Cursor): Contact {
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) val sessionID = cursor.getString(sessionID)
val contact = Contact(sessionID) val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) contact.name = cursor.getStringOrNull(name)
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) contact.nickname = cursor.getStringOrNull(nickname)
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName)) contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let { cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
contact.profilePictureEncryptionKey = Base64.decode(it) contact.profilePictureEncryptionKey = Base64.decode(it)
} }
contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID)) contact.threadID = cursor.getLong(threadID)
contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
return contact 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.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob 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.Job
import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob 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 } 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? { fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor -> 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(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); 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) { /*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID + " = ? AND (CASE " + TYPE; String where = THREAD_ID + " = ? AND (CASE " + TYPE;

View File

@ -17,7 +17,7 @@
*/ */
package org.thoughtcrime.securesms.database; 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.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX;
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
@ -439,32 +439,6 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null); 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() { public long getLatestUnapprovedConversationTimestamp() {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null; Cursor cursor = null;
@ -503,13 +477,15 @@ public class ThreadDatabase extends Database {
} }
public Cursor getApprovedConversationList() { 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 "; "AND " + ARCHIVED + " = 0 ";
return getConversationList(where); return getConversationList(where);
} }
public Cursor getUnapprovedConversationList() { 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.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
@ -771,6 +747,8 @@ public class ThreadDatabase extends Database {
if (shouldDeleteEmptyThread) { if (shouldDeleteEmptyThread) {
deleteThread(threadId); deleteThread(threadId);
return true; return true;
} else {
updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
} }
return false; return false;
} }
@ -828,8 +806,7 @@ public class ThreadDatabase extends Database {
} }
private boolean deleteThreadOnEmpty(long threadId) { private boolean deleteThreadOnEmpty(long threadId) {
Recipient threadRecipient = getRecipientForThreadId(threadId); return false;
return threadRecipient != null && !threadRecipient.isOpenGroupRecipient();
} }
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { 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 lokiV41 = 62;
private static final int lokiV42 = 63; private static final int lokiV42 = 63;
private static final int lokiV43 = 64; private static final int lokiV43 = 64;
private static final int lokiV44 = 65; 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 // 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 int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db"; private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.db"; public static final String DATABASE_NAME = "signal_v4.db";
@ -360,6 +360,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddWrapperHash());
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
} }
@Override @Override
@ -610,6 +613,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs); db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs);
} }
if (oldVersion < lokiV45) {
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -1,14 +1,20 @@
package org.thoughtcrime.securesms.dependencies package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.widget.Toast
import androidx.annotation.StringRes
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @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 @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AppComponent { interface AppComponent {
fun getPrefs(): TextSecurePreferences 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 package org.thoughtcrime.securesms.dependencies
import android.content.Context import android.content.Context
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ServiceComponent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ServiceScoped
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.CallDataProvider import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.CallManager
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import javax.inject.Singleton import javax.inject.Singleton
@ -25,7 +21,7 @@ object CallModule {
@Provides @Provides
@Singleton @Singleton
fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) = fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) =
CallManager(context, audioManagerCompat, storage) CallManager(context, audioManagerCompat, storage)
} }

View File

@ -2,17 +2,25 @@ package org.thoughtcrime.securesms.dependencies
import android.content.Context import android.content.Context
import android.os.Trace import android.os.Trace
import network.loki.messenger.libsession_util.Config
import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig 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.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.UserProfile
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage 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.Log
import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.GroupManager
@ -61,7 +69,7 @@ class ConfigFactory(
listeners -= listener 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") Trace.beginSection("synchronizedWithLog")
val result = synchronized(lock) { val result = synchronized(lock) {
body() body()
@ -72,7 +80,11 @@ class ConfigFactory(
override val user: UserProfile? override val user: UserProfile?
get() = synchronizedWithLog(userLock) { get() = synchronizedWithLog(userLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (!ConfigBase.isNewConfigEnabled(
isConfigForcedOn,
SnodeAPI.nowWithOffset
)
) return null
if (_userConfig == null) { if (_userConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userDump = configDatabase.retrieveConfigAndHashes( val userDump = configDatabase.retrieveConfigAndHashes(
@ -92,7 +104,11 @@ class ConfigFactory(
override val contacts: Contacts? override val contacts: Contacts?
get() = synchronizedWithLog(contactsLock) { get() = synchronizedWithLog(contactsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (!ConfigBase.isNewConfigEnabled(
isConfigForcedOn,
SnodeAPI.nowWithOffset
)
) return null
if (_contacts == null) { if (_contacts == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val contactsDump = configDatabase.retrieveConfigAndHashes( val contactsDump = configDatabase.retrieveConfigAndHashes(
@ -112,7 +128,11 @@ class ConfigFactory(
override val convoVolatile: ConversationVolatileConfig? override val convoVolatile: ConversationVolatileConfig?
get() = synchronizedWithLog(convoVolatileLock) { get() = synchronizedWithLog(convoVolatileLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (!ConfigBase.isNewConfigEnabled(
isConfigForcedOn,
SnodeAPI.nowWithOffset
)
) return null
if (_convoVolatileConfig == null) { if (_convoVolatileConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val convoDump = configDatabase.retrieveConfigAndHashes( val convoDump = configDatabase.retrieveConfigAndHashes(
@ -133,7 +153,11 @@ class ConfigFactory(
override val userGroups: UserGroupsConfig? override val userGroups: UserGroupsConfig?
get() = synchronizedWithLog(userGroupsLock) { get() = synchronizedWithLog(userGroupsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (!ConfigBase.isNewConfigEnabled(
isConfigForcedOn,
SnodeAPI.nowWithOffset
)
) return null
if (_userGroups == null) { if (_userGroups == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userGroupsDump = configDatabase.retrieveConfigAndHashes( val userGroupsDump = configDatabase.retrieveConfigAndHashes(
@ -151,6 +175,86 @@ class ConfigFactory(
_userGroups _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> = override fun getUserConfigs(): List<ConfigBase> =
listOfNotNull(user, contacts, convoVolatile, userGroups) listOfNotNull(user, contacts, convoVolatile, userGroups)
@ -158,13 +262,23 @@ class ConfigFactory(
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) { private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
val dumped = user?.dump() ?: return val dumped = user?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: 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) { private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: 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) { private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
@ -181,10 +295,30 @@ class ConfigFactory(
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) { private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
val dumped = userGroups?.dump() ?: return val dumped = userGroups?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: 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 { try {
listeners.forEach { listener -> listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject) listener.notifyUpdates(forConfigObject)
@ -194,6 +328,8 @@ class ConfigFactory(
is Contacts -> persistContactsConfigDump(timestamp) is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
is UserGroupsConfig -> persistUserGroupsConfigDump(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") else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -214,23 +350,25 @@ class ConfigFactory(
if (openGroupId != null) { if (openGroupId != null) {
val userGroups = userGroups ?: return false val userGroups = userGroups ?: return false
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context) 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 // Not handling the `hidden` behaviour for communities so just indicate the existence
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null) return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
} } else if (groupPublicKey != null) {
else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence // Not handling the `hidden` behaviour for legacy groups so just indicate the existence
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null) return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
} userGroups.getClosedGroup(groupPublicKey) != null
else if (publicKey == userPublicKey) { } else {
userGroups.getLegacyGroupInfo(groupPublicKey) != null
}
} else if (publicKey == userPublicKey) {
val user = user ?: return false val user = user ?: return false
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
} } else if (publicKey != null) {
else if (publicKey != null) {
val contacts = contacts ?: return false val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false val targetContact = contacts.get(publicKey) ?: return false
@ -240,12 +378,38 @@ class ConfigFactory(
return false 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 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) // 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.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper

View File

@ -131,8 +131,13 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { fun provideStorage(@ApplicationContext context: Context,
val storage = Storage(context,openHelper, configFactory) openHelper: SQLCipherOpenHelper,
configFactory: ConfigFactory,
threadDatabase: ThreadDatabase,
pollerFactory: PollerFactory,
toaster: Toaster): Storage {
val storage = Storage(context, openHelper, configFactory, pollerFactory, toaster)
threadDatabase.setUpdateListener(storage) threadDatabase.setUpdateListener(storage)
return 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.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent 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.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.ConfigDatabase
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object SessionUtilModule { object SessionUtilModule {
const val POLLER_SCOPE = "poller_coroutine_scope"
private fun maybeUserEdSecretKey(context: Context): ByteArray? { private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes return edKey.secretKey.asBytes
@ -33,4 +41,19 @@ object SessionUtilModule {
registerListener(context as ConfigFactoryUpdateListener) 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 network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 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.Address
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
@ -26,7 +26,7 @@ object ClosedGroupManager {
// Notify the PN server // Notify the PN server
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
// Stop polling // Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId) storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
if (delete) { 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) { fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) {
val groups = userGroups ?: return val groups = userGroups ?: return
if (!group.isClosedGroup) return if (!group.isLegacyClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())

View File

@ -1,47 +1,44 @@
package org.thoughtcrime.securesms.groups package org.thoughtcrime.securesms.groups
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat import androidx.compose.runtime.SideEffect
import androidx.core.view.isVisible 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.Fragment
import androidx.fragment.app.viewModels import androidx.hilt.navigation.compose.hiltViewModel
import androidx.recyclerview.widget.DividerItemDecoration import com.ramcosta.composedestinations.DestinationsNavHost
import androidx.recyclerview.widget.RecyclerView 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 dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import kotlinx.parcelize.Parcelize
import network.loki.messenger.databinding.FragmentCreateGroupBinding import network.loki.messenger.databinding.FragmentCreateGroupBinding
import nl.komponents.kovenant.ui.failUi import org.session.libsession.messaging.contacts.Contact
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.compose.CreateGroup
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView import org.thoughtcrime.securesms.groups.compose.CreateGroupNavGraph
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.groups.compose.SelectContacts
import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.groups.compose.StateUpdate
import org.thoughtcrime.securesms.util.fadeOut import org.thoughtcrime.securesms.groups.compose.ViewState
import javax.inject.Inject import org.thoughtcrime.securesms.groups.destinations.SelectContactsScreenDestination
import org.thoughtcrime.securesms.ui.AppTheme
@AndroidEntryPoint @AndroidEntryPoint
class CreateGroupFragment : Fragment() { class CreateGroupFragment : Fragment() {
@Inject
lateinit var device: Device
private lateinit var binding: FragmentCreateGroupBinding private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels()
lateinit var delegate: NewConversationDelegate lateinit var delegate: NewConversationDelegate
@ -49,76 +46,91 @@ class CreateGroupFragment : Fragment() {
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentCreateGroupBinding.inflate(inflater) return ComposeView(requireContext()).apply {
return binding.root val getDelegate = { delegate }
} setContent {
AppTheme {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { DestinationsNavHost(
super.onViewCreated(view, savedInstanceState) navGraph = NavGraphs.createGroup,
val adapter = SelectContactsAdapter(requireContext(), GlideApp.with(requireContext())) dependenciesContainerBuilder = {
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } dependency(getDelegate)
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } })
binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks { }
override fun onQueryChanged(query: String) {
adapter.members = viewModel.filter(query).map { it.address.serialize() }
} }
} }
binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
binding.recyclerView.adapter = adapter
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
setDrawable(it)
}
}
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) @Parcelize
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) data class ContactList(val contacts: Set<Contact>) : Parcelable
context.startActivity(intent)
@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.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.database.Storage
import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.groups.compose.StateUpdate
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.groups.compose.ViewState
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CreateGroupViewModel @Inject constructor( class CreateGroupViewModel @Inject constructor(
private val threadDb: ThreadDatabase, private val storage: Storage,
private val textSecurePreferences: TextSecurePreferences
) : ViewModel() { ) : ViewModel() {
private val _recipients = MutableLiveData<List<Recipient>>() private inline fun <reified T> MutableLiveData<T>.update(body: T.() -> T) {
val recipients: LiveData<List<Recipient>> = _recipients this.postValue(body(this.value!!))
}
init { private val _viewState = MutableLiveData(ViewState.DEFAULT.copy())
viewModelScope.launch {
threadDb.approvedConversationList.use { openCursor -> val viewState: LiveData<ViewState> = _viewState
val reader = threadDb.readerFor(openCursor)
val recipients = mutableListOf<Recipient>() fun updateState(stateUpdate: StateUpdate) {
while (true) { when (stateUpdate) {
recipients += reader.next?.recipient ?: break is StateUpdate.AddContacts -> _viewState.update { copy(members = members + stateUpdate.value) }
} is StateUpdate.Description -> _viewState.update { copy(description = stateUpdate.value) }
withContext(Dispatchers.Main) { is StateUpdate.Name -> _viewState.update { copy(name = stateUpdate.value) }
_recipients.value = recipients is StateUpdate.RemoveContact -> _viewState.update { copy(members = members - stateUpdate.value) }
.filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } StateUpdate.Create -> { viewModelScope.launch { tryCreateGroup() } }
}
}
} }
} }
fun filter(query: String): List<Recipient> { val contacts
return _recipients.value?.filter { get() = liveData { emit(storage.getAllContacts()) }
it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true
} ?: emptyList() 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 package org.thoughtcrime.securesms.groups
import android.content.Context
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu import androidx.activity.compose.setContent
import android.view.MenuItem import com.ramcosta.composedestinations.DestinationsNavHost
import android.view.View import com.ramcosta.composedestinations.navigation.dependency
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 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.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.groups.compose.EditGroupInviteViewModel
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.groups.compose.EditGroupViewModel
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupScreenDestination
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.ui.AppTheme
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 import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { 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
companion object { companion object {
@JvmStatic val groupIDKey = "groupIDKey" const val groupIDKey = "EditClosedGroupActivity_groupID"
private val loaderID = 0
val addUsersRequestCode = 124
val legacyGroupSizeLimit = 10
} }
// region Lifecycle @Inject lateinit var editFactory: EditGroupViewModel.Factory
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { @Inject lateinit var inviteFactory: EditGroupInviteViewModel.Factory
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_edit_closed_group)
supportActionBar!!.setHomeAsUpIndicator( private fun onFinish() {
ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable)) finish()
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()
}
})
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
menuInflater.inflate(R.menu.menu_edit_closed_group, menu) setContent {
return allMembers.isNotEmpty() && !isLoading
}
// endregion
// region Updating AppTheme {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { DestinationsNavHost(
super.onActivityResult(requestCode, resultCode, data) navGraph = NavGraphs.editGroup,
when (requestCode) { dependenciesContainerBuilder = {
addUsersRequestCode -> { dependency(NavGraphs.editGroup) {
if (resultCode != RESULT_OK) return editFactory.create(intent.getStringExtra(groupIDKey)!!, contentResolver)
if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return }
dependency(NavGraphs.editGroup) {
val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet() inviteFactory.create(intent.getStringExtra(groupIDKey)!!)
members.addAll(selectedContacts) }
updateMembers() dependency(EditClosedGroupScreenDestination) {
} ::onFinish
} }
}
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)
} }
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.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.AsyncLoader 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 groupDatabase = DatabaseComponent.get(context).groupDatabase()
val members = groupDatabase.getGroupMembers(groupID, true) val members = groupDatabase.getGroupMembers(groupID, true)
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID) val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
return EditClosedGroupActivity.GroupMembers( return EditLegacyClosedGroupActivity.GroupMembers(
members.map { members.map {
it.address.toString() 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)) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
push(intent) push(intent)
} }
is GlobalSearchAdapter.Model.GroupConversation -> { is GlobalSearchAdapter.Model.LegacyGroupConversation -> {
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId) val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false)) val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
if (threadId >= 0) { if (threadId >= 0) {
@ -258,7 +258,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
globalSearchViewModel.result.collect { result -> globalSearchViewModel.result.collect { result ->
val currentUserPublicKey = publicKey val currentUserPublicKey = publicKey
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + 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() val contactResults = contactAndGroupList.toMutableList()
@ -334,9 +334,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
private fun setupMessageRequestsBanner() { private fun setupMessageRequestsBanner() {
val messageRequestCount = threadDb.unapprovedConversationCount val messageRequestCount = threadDb.unapprovedConversationList.use { it.count }
// Set up message requests // Set up message requests
if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) { if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests() && messageRequestCount != homeAdapter.requestCount) {
with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) { with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) {
unreadCountTextView.text = messageRequestCount.toString() unreadCountTextView.text = messageRequestCount.toString()
timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString( timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(
@ -352,13 +352,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
if (hadHeader) homeAdapter.notifyItemChanged(0) if (hadHeader) homeAdapter.notifyItemChanged(0)
else homeAdapter.notifyItemInserted(0) else homeAdapter.notifyItemInserted(0)
} }
} else { } else if (messageRequestCount == 0) {
val hadHeader = homeAdapter.hasHeaderView() val hadHeader = homeAdapter.hasHeaderView()
homeAdapter.header = null homeAdapter.header = null
if (hadHeader) { if (hadHeader) {
homeAdapter.notifyItemRemoved(0) homeAdapter.notifyItemRemoved(0)
} }
} }
homeAdapter.requestCount = messageRequestCount
} }
private fun updateLegacyConfigView() { private fun updateLegacyConfigView() {
@ -644,14 +645,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Cancel any outstanding jobs // Cancel any outstanding jobs
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
// Send a leave group message if this is an active closed group // 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 { try {
GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
?.let { MessageSender.explicitLeave(it, false) } ?.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 // Delete the conversation
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
if (v2OpenGroup != null) { if (v2OpenGroup != null) {

View File

@ -38,6 +38,7 @@ class HomeAdapter(
} }
fun hasHeaderView(): Boolean = header != null fun hasHeaderView(): Boolean = header != null
var requestCount = 0
private val headerCount: Int private val headerCount: Int
get() = if (header == null) 0 else 1 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.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding 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.GroupRecord
import org.session.libsession.utilities.recipients.Recipient 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 org.thoughtcrime.securesms.search.model.MessageResult
import java.security.InvalidParameterException import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel 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) { fun bind(query: String, model: Model) {
binding.searchResultProfilePicture.recycle() binding.searchResultProfilePicture.recycle()
when (model) { when (model) {
is Model.GroupConversation -> bindModel(query, model) is Model.LegacyGroupConversation -> bindModel(query, model)
is Model.Contact -> bindModel(query, model) is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model) is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(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 Header(@StringRes val title: Int) : Model()
data class SavedMessages(val currentUserPublicKey: String): Model() data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel) : 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() 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.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
@ -65,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
binding.searchResultSubtitle.isVisible = true binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
} }
is GroupConversation -> { is LegacyGroupConversation -> {
binding.searchResultTitle.text = getHighlight( binding.searchResultTitle.text = getHighlight(
query, query,
model.groupRecord.title model.groupRecord.title
@ -86,10 +86,10 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query) 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.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
binding.searchResultProfilePicture.update(threadRecipient) binding.searchResultProfilePicture.update(threadRecipient)
@ -102,7 +102,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
val address = it.address.serialize() val address = it.address.serialize()
it.name ?: "${address.take(4)}...${address.takeLast(4)}" it.name ?: "${address.take(4)}...${address.takeLast(4)}"
} }
if (model.groupRecord.isClosedGroup) { if (model.groupRecord.isLegacyClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
} }
@ -132,11 +132,6 @@ fun ContentView.bindModel(query: String?, model: Message) {
binding.searchResultProfilePicture.isVisible = true binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false binding.searchResultSavedMessages.isVisible = false
binding.searchResultTimestamp.isVisible = true 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.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder() val textSpannable = SpannableStringBuilder()

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messagerequests
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.database.Cursor import android.database.Cursor
import android.os.Build
import android.text.SpannableString import android.text.SpannableString
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
@ -61,10 +62,14 @@ class MessageRequestsAdapter(
val item = popupMenu.menu.getItem(i) val item = popupMenu.menu.getItem(i)
val s = SpannableString(item.title) val s = SpannableString(item.title)
s.setSpan(ForegroundColorSpan(context.getColor(R.color.destructive)), 0, s.length, 0) 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 item.title = s
} }
popupMenu.setForceShowIcon(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
popupMenu.setForceShowIcon(true)
}
popupMenu.show() popupMenu.show()
} }

View File

@ -7,8 +7,8 @@ import androidx.work.Constraints
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters 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.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.MessageReceiveParameters 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.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -121,7 +121,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
// Closed groups // Closed groups
if (requestTargets.contains(Targets.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 storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } 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.open_groups.OpenGroup;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; 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.messaging.utilities.SodiumUtilities;
import org.session.libsession.snode.SnodeAPI; import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address; 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.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.IdPrefix; import org.session.libsignal.utilities.IdPrefix;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.SessionId;
import org.session.libsignal.utilities.Util; import org.session.libsignal.utilities.Util;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.contacts.ContactUtil; import org.thoughtcrime.securesms.contacts.ContactUtil;
@ -555,7 +555,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
if (openGroup != null && edKeyPair != null) { if (openGroup != null && edKeyPair != null) {
KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair);
if (blindedKeyPair != null) { if (blindedKeyPair != null) {
return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).hexString();
} }
} }
return null; return null;

View File

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

View File

@ -53,7 +53,7 @@ class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver)
val requestParameters = SubscriptionRequest( val requestParameters = SubscriptionRequest(
pubkey = publicKey, pubkey = publicKey,
session_ed25519 = userEd25519Key.publicKey.asHexString, session_ed25519 = userEd25519Key.publicKey.asHexString,
namespaces = listOf(Namespace.DEFAULT), namespaces = listOf(Namespace.DEFAULT()),
data = true, // only permit data subscription for now (?) data = true, // only permit data subscription for now (?)
service = device.service, service = device.service,
sig_ts = timestamp, sig_ts = timestamp,

View File

@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@ -28,7 +29,7 @@ import org.thoughtcrime.securesms.util.adapter.SelectableItem
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BlockedContactsViewModel @Inject constructor(private val storage: Storage): ViewModel() { class BlockedContactsViewModel @Inject constructor(private val storage: StorageProtocol): ViewModel() {
private val executor = viewModelScope + SupervisorJob() private val executor = viewModelScope + SupervisorJob()

View File

@ -104,7 +104,8 @@ class ClearAllDataDialog : DialogFragment() {
if (!deleteNetworkMessages) { if (!deleteNetworkMessages) {
try { try {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() // TODO: maybe convert this to a blocking config job
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext())
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Loki", "Failed to force sync", e) Log.e("Loki", "Failed to force sync", e)
} }

View File

@ -9,11 +9,10 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView; 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.ProfilePictureView;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.mms.GlideApp;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;

View File

@ -77,7 +77,7 @@ interface ConversationRepository {
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit>
fun declineMessageRequest(threadId: Long) fun declineMessageRequest(threadId: Long, recipient: Recipient)
fun hasReceived(threadId: Long): Boolean fun hasReceived(threadId: Long): Boolean
@ -286,8 +286,7 @@ class DefaultConversationRepository @Inject constructor(
} }
override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> { override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> {
sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) declineMessageRequest(thread.threadId, thread.recipient)
storage.deleteConversation(thread.threadId)
return ResultOf.Success(Unit) return ResultOf.Success(Unit)
} }
@ -306,19 +305,27 @@ class DefaultConversationRepository @Inject constructor(
override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation -> override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation ->
storage.setRecipientApproved(recipient, true) storage.setRecipientApproved(recipient, true)
val message = MessageRequestResponse(true) if (recipient.isClosedGroupRecipient) {
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) storage.respondToClosedGroupInvitation(recipient, true)
.success { } else {
threadDb.setHasSent(threadId, true) val message = MessageRequestResponse(true)
continuation.resume(ResultOf.Success(Unit)) MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
}.fail { error -> .success {
continuation.resumeWithException(error) 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) sessionJobDb.cancelPendingMessageSendJobs(threadId)
storage.deleteConversation(threadId) if (recipient.isClosedGroupRecipient) {
storage.respondToClosedGroupInvitation(recipient, false)
} else {
storage.deleteConversation(threadId)
}
} }
override fun hasReceived(threadId: Long): Boolean { 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.contacts.Contact
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities

View File

@ -2,18 +2,28 @@ package org.thoughtcrime.securesms.ui
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi 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.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonColors
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.Colors import androidx.compose.material.Colors
@ -22,15 +32,24 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton import androidx.compose.material.TextButton
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.pager.HorizontalPagerIndicator import com.google.accompanist.pager.HorizontalPagerIndicator
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -95,7 +114,7 @@ fun CellWithPaddingAndMargin(
} }
} }
private val Colors.cellColor: Color val Colors.cellColor: Color
@Composable @Composable
get() = LocalExtraColors.current.settingsBackground 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 import network.loki.messenger.R
val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { error("No Custom Attribute value provided") } val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { error("No Custom Attribute value provided") }
val LocalPreviewMode = staticCompositionLocalOf { false }
data class ExtraColors( data class ExtraColors(
val settingsBackground: Color, val settingsBackground: Color,
val destructive: Color
) )
/** /**
@ -34,6 +36,7 @@ fun AppTheme(
val extraColors = LocalContext.current.run { val extraColors = LocalContext.current.run {
ExtraColors( ExtraColors(
settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground),
destructive = Color(getColor(R.color.destructive)),
) )
} }
@ -56,7 +59,8 @@ fun PreviewTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId),
LocalPreviewMode provides true
) { ) {
AppTheme { AppTheme {
Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { 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.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.ConfigurationSyncJob import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.JobQueue 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.crypto.ecc.DjbECPublicKey
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix 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.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import java.util.Timer import java.util.Timer
import java.util.concurrent.ConcurrentLinkedDeque
object ConfigurationMessageUtilities { object ConfigurationMessageUtilities {
private val debouncer = WindowDebouncer(3000, Timer()) 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 { debouncer.publish {
// don't schedule job if we already have one // don't schedule job if we already have one
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val ourDestination = Destination.Contact(userPublicKey) val configFactory = MessagingModuleConfiguration.shared.configFactory
val currentStorageJob = storage.getConfigSyncJob(ourDestination) val destinations = synchronized(destinationUpdater) {
if (currentStorageJob != null) { val objects = pendingDestinations.toList()
(currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) pendingDestinations.clear()
return@publish 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 forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
scheduleConfigSync(userPublicKey) scheduleConfigSync(Destination.Contact(userPublicKey))
return return
} }
val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context)
@ -82,34 +101,21 @@ object ConfigurationMessageUtilities {
TextSecurePreferences.setLastConfigurationSyncTime(context, now) 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 // 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 forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
// schedule job if none exist // schedule job if none exist
// don't schedule job if we already have one // don't schedule job if we already have one
scheduleConfigSync(userPublicKey) scheduleConfigSync(Destination.Contact(userPublicKey))
return Promise.ofSuccess(Unit)
} }
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 private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes
@ -199,6 +205,11 @@ object ConfigurationMessageUtilities {
convoConfig.getOrConstructCommunity(base, room, pubKey) convoConfig.getOrConstructCommunity(base, room, pubKey)
} }
recipient.isClosedGroupRecipient -> { 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()) val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
convoConfig.getOrConstructLegacyGroup(groupPublicKey) convoConfig.getOrConstructLegacyGroup(groupPublicKey)
} }
@ -241,7 +252,7 @@ object ConfigurationMessageUtilities {
} }
val allLgc = storage.getAllGroups(includeInactive = false).filter { 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 -> }.mapNotNull { group ->
val groupAddress = Address.fromSerialized(group.encodedId) val groupAddress = Address.fromSerialized(group.encodedId)
val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString()
@ -252,7 +263,7 @@ object ConfigurationMessageUtilities {
val admins = group.admins.map { it.serialize() to true }.toMap() 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() val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap()
GroupInfo.LegacyGroupInfo( GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey, sessionId = SessionId.from(groupPublicKey),
name = group.title, name = group.title,
members = admins + members, members = admins + members,
priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
@ -273,13 +284,13 @@ object ConfigurationMessageUtilities {
@JvmField @JvmField
val DELETE_INACTIVE_GROUPS: String = """ 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 ${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.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() """.trimIndent()
@JvmField @JvmField
val DELETE_INACTIVE_ONE_TO_ONES: String = """ 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() """.trimIndent()
} }

View File

@ -13,6 +13,8 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool
&& recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) {
return getOneToOne(recipient.address.serialize())?.unread == true return getOneToOne(recipient.address.serialize())?.unread == true
} else if (recipient.isClosedGroupRecipient) { } else if (recipient.isClosedGroupRecipient) {
return getClosedGroup(recipient.address.serialize())?.unread == true
} else if (recipient.isLegacyClosedGroupRecipient) {
return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true
} else if (recipient.isOpenGroupRecipient) { } else if (recipient.isOpenGroupRecipient) {
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false 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" android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"> tools:context="org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity">
<LinearLayout <LinearLayout
android:id="@+id/mainContentContainer" android:id="@+id/mainContentContainer"

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