Merge 2628f03fcf
into b96a5c561e
This commit is contained in:
commit
5ac4192539
|
@ -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
|
|
@ -17,12 +17,13 @@ buildscript {
|
|||
plugins {
|
||||
id 'kotlin-kapt'
|
||||
id 'com.google.dagger.hilt.android'
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'app.cash.molecule'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'witness'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'dagger.hilt.android.plugin'
|
||||
|
@ -47,12 +48,12 @@ android {
|
|||
useLibrary 'org.apache.http.legacy'
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '11'
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
|
@ -78,7 +79,7 @@ android {
|
|||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.4.7'
|
||||
kotlinCompilerExtensionVersion '1.5.3'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
|
@ -205,8 +206,14 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.46.1")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.44")
|
||||
implementation("com.google.dagger:hilt-android:$daggerVersion")
|
||||
kapt("com.google.dagger:hilt-android-compiler:$daggerVersion")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
implementation "io.github.raamcosta.compose-destinations:core:$composeDestinationsVersion"
|
||||
ksp "io.github.raamcosta.compose-destinations:ksp:$composeDestinationsVersion"
|
||||
|
||||
|
||||
|
||||
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
|
@ -299,9 +306,9 @@ dependencies {
|
|||
implementation "com.opencsv:opencsv:4.6"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
testImplementation 'org.assertj:assertj-core:3.11.1'
|
||||
testImplementation "org.mockito:mockito-inline:4.10.0"
|
||||
testImplementation "org.mockito:mockito-inline:4.11.0"
|
||||
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
||||
androidTestImplementation "org.mockito:mockito-android:4.10.0"
|
||||
androidTestImplementation "org.mockito:mockito-android:4.11.0"
|
||||
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
||||
testImplementation "androidx.test:core:$testCoreVersion"
|
||||
testImplementation "androidx.arch.core:core-testing:2.2.0"
|
||||
|
@ -313,7 +320,6 @@ dependencies {
|
|||
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
|
||||
exclude group: 'org.jetbrains.kotlin'
|
||||
}
|
||||
|
||||
// AndroidJUnitRunner and JUnit Rules
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
|
@ -331,20 +337,22 @@ dependencies {
|
|||
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3"
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3"
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||
|
||||
testImplementation 'org.robolectric:robolectric:4.4'
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
|
||||
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
||||
implementation 'androidx.compose.ui:ui:1.5.2'
|
||||
implementation 'androidx.compose.ui:ui-tooling:1.5.2'
|
||||
implementation 'androidx.compose.ui:ui:1.5.3'
|
||||
implementation 'androidx.compose.ui:ui-tooling:1.5.3'
|
||||
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
|
||||
implementation "androidx.compose.runtime:runtime-livedata:1.5.2"
|
||||
implementation "androidx.compose.runtime:runtime-livedata:1.5.3"
|
||||
|
||||
implementation 'androidx.compose.foundation:foundation-layout:1.5.2'
|
||||
implementation 'androidx.compose.material:material:1.5.2'
|
||||
implementation 'androidx.compose.foundation:foundation-layout:1.5.3'
|
||||
implementation 'androidx.compose.material:material:1.5.3'
|
||||
}
|
||||
|
||||
static def getLastCommitTimestamp() {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="network.loki.messenger.test">
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<uses-library android:name="android.test.runner"
|
||||
android:required="false" />
|
||||
|
||||
<activity android:name="androidx.activity.ComponentActivity"/>
|
||||
|
||||
</application>
|
||||
</manifest>
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,14 +1,10 @@
|
|||
package network.loki.messenger
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Instrumentation
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.Espresso.pressBack
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
|
||||
|
@ -20,11 +16,11 @@ import androidx.test.espresso.matcher.ViewMatchers.withSubstring
|
|||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.adevinta.android.barista.interaction.PermissionGranter
|
||||
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
|
||||
import org.hamcrest.Matcher
|
||||
import network.loki.messenger.util.sendMessage
|
||||
import network.loki.messenger.util.setupLoggedInState
|
||||
import network.loki.messenger.util.waitFor
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.junit.After
|
||||
|
@ -36,12 +32,10 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
|
|||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
|
||||
import org.thoughtcrime.securesms.home.HomeActivity
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
@SmallTest
|
||||
class HomeActivityTests {
|
||||
|
||||
@get:Rule
|
||||
|
@ -59,38 +53,6 @@ class HomeActivityTests {
|
|||
InstrumentationRegistry.getInstrumentation().removeMonitor(activityMonitor)
|
||||
}
|
||||
|
||||
private fun sendMessage(messageToSend: String, linkPreview: LinkPreview? = null) {
|
||||
// assume in chat activity
|
||||
onView(allOf(isDescendantOfA(withId(R.id.inputBar)),withId(R.id.inputBarEditText))).perform(ViewActions.replaceText(messageToSend))
|
||||
if (linkPreview != null) {
|
||||
val activity = activityMonitor.waitForActivity() as ConversationActivityV2
|
||||
val glide = GlideApp.with(activity)
|
||||
activity.findViewById<InputBar>(R.id.inputBar).updateLinkPreviewDraft(glide, linkPreview)
|
||||
}
|
||||
onView(allOf(isDescendantOfA(withId(R.id.inputBar)),inputButtonWithDrawable(R.drawable.ic_arrow_up))).perform(ViewActions.click())
|
||||
// TODO: text can flaky on cursor reload, figure out a better way to wait for the UI to settle with new data
|
||||
onView(isRoot()).perform(waitFor(500))
|
||||
}
|
||||
|
||||
private fun setupLoggedInState(hasViewedSeed: Boolean = false) {
|
||||
// landing activity
|
||||
onView(withId(R.id.registerButton)).perform(ViewActions.click())
|
||||
// session ID - register activity
|
||||
onView(withId(R.id.registerButton)).perform(ViewActions.click())
|
||||
// display name selection
|
||||
onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123"))
|
||||
onView(withId(R.id.registerButton)).perform(ViewActions.click())
|
||||
// PN select
|
||||
if (hasViewedSeed) {
|
||||
// has viewed seed is set to false after register activity
|
||||
TextSecurePreferences.setHasViewedSeed(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||
}
|
||||
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
|
||||
onView(withId(R.id.registerButton)).perform(ViewActions.click())
|
||||
// allow notification permission
|
||||
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
private fun goToMyChat() {
|
||||
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
|
||||
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||
|
@ -134,11 +96,13 @@ class HomeActivityTests {
|
|||
setupLoggedInState()
|
||||
goToMyChat()
|
||||
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||
sendMessage("howdy")
|
||||
sendMessage("test")
|
||||
// tests url rewriter doesn't crash
|
||||
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
|
||||
sendMessage("https://www.ámazon.com")
|
||||
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
|
||||
sendMessage("howdy")
|
||||
sendMessage("test")
|
||||
// tests url rewriter doesn't crash
|
||||
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
|
||||
sendMessage("https://www.ámazon.com")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -148,7 +112,9 @@ class HomeActivityTests {
|
|||
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||
// given the link url text
|
||||
val url = "https://www.ámazon.com"
|
||||
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
||||
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
|
||||
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
||||
}
|
||||
|
||||
// when the URL span is clicked
|
||||
onView(withSubstring(url)).perform(ViewActions.click())
|
||||
|
@ -162,21 +128,4 @@ class HomeActivityTests {
|
|||
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform action of waiting for a specific time.
|
||||
*/
|
||||
fun waitFor(millis: Long): ViewAction {
|
||||
return object : ViewAction {
|
||||
override fun getConstraints(): Matcher<View>? {
|
||||
return isRoot()
|
||||
}
|
||||
|
||||
override fun getDescription(): String = "Wait for $millis milliseconds."
|
||||
|
||||
override fun perform(uiController: UiController, view: View?) {
|
||||
uiController.loopMainThreadForAtLeast(millis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -9,12 +9,15 @@ import network.loki.messenger.libsession_util.ConfigBase
|
|||
import network.loki.messenger.libsession_util.Contacts
|
||||
import network.loki.messenger.libsession_util.util.Contact
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import network.loki.messenger.util.applySpiedStorage
|
||||
import network.loki.messenger.util.maybeGetUserInfo
|
||||
import network.loki.messenger.util.randomSeedBytes
|
||||
import network.loki.messenger.util.randomSessionId
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.argThat
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.spy
|
||||
import org.mockito.kotlin.verify
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
|
@ -22,32 +25,15 @@ import org.session.libsignal.utilities.KeyHelper
|
|||
import org.session.libsignal.utilities.hexEncodedPublicKey
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@SmallTest
|
||||
class LibSessionTests {
|
||||
|
||||
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
|
||||
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
|
||||
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
|
||||
|
||||
private var fakeHashI = 0
|
||||
private val nextFakeHash: String
|
||||
get() = "fakehash${fakeHashI++}"
|
||||
|
||||
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||
val prefs = appContext.prefs
|
||||
val localUserPublicKey = prefs.getLocalNumber()
|
||||
val secretKey = with(appContext) {
|
||||
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
|
||||
edKey.secretKey.asBytes
|
||||
}
|
||||
return if (localUserPublicKey == null || secretKey == null) null
|
||||
else secretKey to localUserPublicKey
|
||||
}
|
||||
|
||||
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
|
||||
val (key,_) = maybeGetUserInfo()!!
|
||||
val contacts = Contacts.Companion.newInstance(key)
|
||||
|
@ -80,9 +66,8 @@ class LibSessionTests {
|
|||
|
||||
@Test
|
||||
fun migration_one_to_ones() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||
val storageSpy = spy(app.storage)
|
||||
app.storage = storageSpy
|
||||
val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||
val storage = applicationContext.applySpiedStorage()
|
||||
|
||||
val newContactId = randomSessionId()
|
||||
val singleContact = Contact(
|
||||
|
@ -93,10 +78,10 @@ class LibSessionTests {
|
|||
val newContactMerge = buildContactMessage(listOf(singleContact))
|
||||
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||
fakePollNewConfig(contacts, newContactMerge)
|
||||
verify(storageSpy).addLibSessionContacts(argThat {
|
||||
verify(storage).addLibSessionContacts(argThat {
|
||||
first().let { it.id == newContactId && it.approved } && size == 1
|
||||
})
|
||||
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
|
||||
verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -152,8 +152,13 @@
|
|||
android:theme="@style/Theme.Session.DayNight.FlatActionBar"
|
||||
android:label="@string/blocked_contacts_title"
|
||||
/>
|
||||
<activity
|
||||
android:name="org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity"
|
||||
android:label="@string/activity_edit_closed_group_title"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"
|
||||
android:theme="@style/Theme.Session.DayNight.NoActionBar"
|
||||
android:label="@string/activity_edit_closed_group_title"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
|
@ -232,6 +237,13 @@
|
|||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||
</activity>
|
||||
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Session.DayNight.NoActionBar"/>
|
||||
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationNotificationSettingsActivity"
|
||||
android:label="@string/activity_notification_settings_title"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Session.DayNight"/>
|
||||
<activity
|
||||
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
|
||||
android:screenOrientation="portrait"
|
||||
|
|
|
@ -32,11 +32,12 @@ import androidx.lifecycle.LifecycleOwner;
|
|||
import androidx.lifecycle.ProcessLifecycleOwner;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.session.libsession.avatars.AvatarHelper;
|
||||
import org.session.libsession.database.MessageDataProvider;
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration;
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2;
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2;
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
|
||||
import org.session.libsession.snode.SnodeModule;
|
||||
import org.session.libsession.utilities.Address;
|
||||
|
@ -65,6 +66,7 @@ import org.thoughtcrime.securesms.dependencies.AppComponent;
|
|||
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
||||
import org.thoughtcrime.securesms.dependencies.PollerFactory;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||
|
@ -108,9 +110,8 @@ import javax.inject.Inject;
|
|||
import dagger.hilt.EntryPoints;
|
||||
import dagger.hilt.android.HiltAndroidApp;
|
||||
import kotlin.Unit;
|
||||
import kotlinx.coroutines.Job;
|
||||
import network.loki.messenger.BuildConfig;
|
||||
import network.loki.messenger.libsession_util.ConfigBase;
|
||||
import network.loki.messenger.libsession_util.Config;
|
||||
import network.loki.messenger.libsession_util.UserProfile;
|
||||
|
||||
/**
|
||||
|
@ -136,7 +137,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
public MessageNotifier messageNotifier = null;
|
||||
public Poller poller = null;
|
||||
public Broadcaster broadcaster = null;
|
||||
private Job firebaseInstanceIdJob;
|
||||
private WindowDebouncer conversationListDebouncer;
|
||||
private HandlerThread conversationListHandlerThread;
|
||||
private Handler conversationListHandler;
|
||||
|
@ -149,6 +149,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
@Inject TextSecurePreferences textSecurePreferences;
|
||||
@Inject PushRegistry pushRegistry;
|
||||
@Inject ConfigFactory configFactory;
|
||||
@Inject PollerFactory pollerFactory;
|
||||
CallMessageProcessor callMessageProcessor;
|
||||
MessagingModuleConfiguration messagingModuleConfiguration;
|
||||
|
||||
|
@ -197,7 +198,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
}
|
||||
|
||||
@Override
|
||||
public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
|
||||
public void notifyUpdates(@NotNull Config forConfigObject) {
|
||||
// forward to the config factory / storage ig
|
||||
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
|
||||
textSecurePreferences.setConfigurationMessageSynced(true);
|
||||
|
@ -219,7 +220,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
messageDataProvider,
|
||||
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
|
||||
configFactory
|
||||
);
|
||||
);
|
||||
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
||||
Log.i(TAG, "onCreate()");
|
||||
startKovenant();
|
||||
|
@ -282,13 +283,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
if (poller != null) {
|
||||
poller.stopIfNeeded();
|
||||
}
|
||||
ClosedGroupPollerV2.getShared().stopAll();
|
||||
pollerFactory.stopAll();
|
||||
LegacyClosedGroupPollerV2.getShared().stopAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTerminate() {
|
||||
stopKovenant(); // Loki
|
||||
OpenGroupManager.INSTANCE.stopPolling();
|
||||
pollerFactory.stopAll();
|
||||
super.onTerminate();
|
||||
}
|
||||
|
||||
|
@ -437,7 +440,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
poller.setUserPublicKey(userPublicKey);
|
||||
return;
|
||||
}
|
||||
poller = new Poller(configFactory, new Timer());
|
||||
poller = new Poller(configFactory);
|
||||
}
|
||||
|
||||
public void startPollingIfNeeded() {
|
||||
|
@ -445,7 +448,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
if (poller != null) {
|
||||
poller.startIfNeeded();
|
||||
}
|
||||
ClosedGroupPollerV2.getShared().start();
|
||||
pollerFactory.startAll();
|
||||
LegacyClosedGroupPollerV2.getShared().start();
|
||||
}
|
||||
|
||||
private void resubmitProfilePictureIfNeeded() {
|
||||
|
@ -500,9 +504,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||
}
|
||||
|
||||
public void clearAllData(boolean isMigratingToV2KeyPair) {
|
||||
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
|
||||
firebaseInstanceIdJob.cancel(null);
|
||||
}
|
||||
String displayName = TextSecurePreferences.getProfileName(this);
|
||||
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
|
||||
TextSecurePreferences.clearAll(this);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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?)
|
||||
}
|
||||
|
||||
}
|
|
@ -20,7 +20,6 @@ import android.annotation.SuppressLint;
|
|||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
import android.database.Cursor;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
@ -35,7 +34,6 @@ import android.widget.Toast;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
@ -45,32 +43,32 @@ import androidx.fragment.app.FragmentStatePagerAdapter;
|
|||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration;
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
|
||||
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.session.libsession.utilities.GroupRecord;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.ViewUtil;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
@ -82,423 +80,450 @@ import network.loki.messenger.R;
|
|||
/**
|
||||
* Activity for displaying media attachments in-app
|
||||
*/
|
||||
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
|
||||
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements View.OnClickListener {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private final static String TAG = MediaOverviewActivity.class.getSimpleName();
|
||||
|
||||
public static final String ADDRESS_EXTRA = "address";
|
||||
|
||||
private Toolbar toolbar;
|
||||
private TabLayout tabLayout;
|
||||
private ViewPager viewPager;
|
||||
private Recipient recipient;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
setContentView(R.layout.media_overview_activity);
|
||||
|
||||
initializeResources();
|
||||
initializeToolbar();
|
||||
|
||||
this.tabLayout.setupWithViewPager(viewPager);
|
||||
this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home: finish(); return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
|
||||
|
||||
this.viewPager = ViewUtil.findById(this, R.id.pager);
|
||||
this.toolbar = ViewUtil.findById(this, R.id.toolbar);
|
||||
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
|
||||
this.recipient = Recipient.from(this, address, true);
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
setSupportActionBar(this.toolbar);
|
||||
ActionBar actionBar = getSupportActionBar();
|
||||
actionBar.setTitle(recipient.toShortString());
|
||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
||||
actionBar.setHomeButtonEnabled(true);
|
||||
this.recipient.addListener(recipient -> {
|
||||
Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
|
||||
});
|
||||
}
|
||||
|
||||
public void onEnterMultiSelect() {
|
||||
tabLayout.setEnabled(false);
|
||||
viewPager.setEnabled(false);
|
||||
}
|
||||
|
||||
public void onExitMultiSelect() {
|
||||
tabLayout.setEnabled(true);
|
||||
viewPager.setEnabled(true);
|
||||
}
|
||||
|
||||
private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
|
||||
|
||||
MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
|
||||
super(fragmentManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fragment getItem(int position) {
|
||||
Fragment fragment;
|
||||
|
||||
if (position == 0) fragment = new MediaOverviewGalleryFragment();
|
||||
else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
|
||||
else throw new AssertionError();
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
|
||||
args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
|
||||
|
||||
fragment.setArguments(args);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getPageTitle(int position) {
|
||||
if (position == 0) return getString(R.string.MediaOverviewActivity_Media);
|
||||
else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
|
||||
else throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
public static abstract class MediaOverviewFragment<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
|
||||
@SuppressWarnings("unused")
|
||||
private final static String TAG = MediaOverviewActivity.class.getSimpleName();
|
||||
|
||||
public static final String ADDRESS_EXTRA = "address";
|
||||
public static final String LOCALE_EXTRA = "locale_extra";
|
||||
|
||||
protected TextView noMedia;
|
||||
protected Recipient recipient;
|
||||
protected RecyclerView recyclerView;
|
||||
protected Locale locale;
|
||||
private Toolbar toolbar;
|
||||
private TabLayout tabLayout;
|
||||
private ViewPager viewPager;
|
||||
private Recipient recipient;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
setContentView(R.layout.media_overview_activity);
|
||||
|
||||
String address = getArguments().getString(ADDRESS_EXTRA);
|
||||
Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA);
|
||||
initializeResources();
|
||||
initializeToolbar();
|
||||
|
||||
if (address == null) throw new AssertionError();
|
||||
if (locale == null) throw new AssertionError();
|
||||
|
||||
this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
|
||||
this.locale = locale;
|
||||
|
||||
getLoaderManager().initLoader(0, null, this);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MediaOverviewGalleryFragment
|
||||
extends MediaOverviewFragment<BucketedThreadMedia>
|
||||
implements MediaGalleryAdapter.ItemClickListener
|
||||
{
|
||||
|
||||
private StickyHeaderGridLayoutManager gridManager;
|
||||
private ActionMode actionMode;
|
||||
private ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
|
||||
|
||||
this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
|
||||
this.noMedia = ViewUtil.findById(view, R.id.no_images);
|
||||
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
|
||||
|
||||
this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(),
|
||||
GlideApp.with(this),
|
||||
new BucketedThreadMedia(getContext()),
|
||||
locale,
|
||||
this));
|
||||
this.recyclerView.setLayoutManager(gridManager);
|
||||
this.recyclerView.setHasFixedSize(true);
|
||||
|
||||
return view;
|
||||
this.tabLayout.setupWithViewPager(viewPager);
|
||||
this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
if (gridManager != null) {
|
||||
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
|
||||
this.recyclerView.setLayoutManager(gridManager);
|
||||
}
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
|
||||
|
||||
this.viewPager = ViewUtil.findById(this, R.id.pager);
|
||||
this.toolbar = ViewUtil.findById(this, R.id.toolbar);
|
||||
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
|
||||
this.recipient = Recipient.from(this, address, true);
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
setSupportActionBar(this.toolbar);
|
||||
ActionBar actionBar = getSupportActionBar();
|
||||
actionBar.setTitle(recipient.toShortString());
|
||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
||||
actionBar.setHomeButtonEnabled(true);
|
||||
this.recipient.addListener(recipient -> {
|
||||
Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
|
||||
});
|
||||
View clearButton = toolbar.findViewById(R.id.clearMedia);
|
||||
if (!this.recipient.isClosedGroupRecipient()) {
|
||||
clearButton.setVisibility(View.GONE);
|
||||
} else {
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
||||
GroupRecord groupRecord = MessagingModuleConfiguration.getShared().getStorage().getGroup(this.recipient.getAddress().toGroupString());
|
||||
if (userPublicKey == null || groupRecord == null) {
|
||||
clearButton.setVisibility(View.GONE);
|
||||
} else {
|
||||
boolean isUserAdmin = groupRecord.getAdmins().contains(Address.fromSerialized(userPublicKey));
|
||||
clearButton.setVisibility(isUserAdmin ? View.VISIBLE : View.GONE);
|
||||
clearButton.setOnClickListener(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void onEnterMultiSelect() {
|
||||
tabLayout.setEnabled(false);
|
||||
viewPager.setEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<BucketedThreadMedia> onCreateLoader(int i, Bundle bundle) {
|
||||
return new BucketedThreadMediaLoader(getContext(), recipient.getAddress());
|
||||
public void onClick(View v) {
|
||||
if (v.getId() == R.id.clearMedia) {
|
||||
// TODO: future chunk
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<BucketedThreadMedia> loader, BucketedThreadMedia bucketedThreadMedia) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia);
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
|
||||
|
||||
noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
public void onExitMultiSelect() {
|
||||
tabLayout.setEnabled(true);
|
||||
viewPager.setEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<BucketedThreadMedia> cursorLoader) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext()));
|
||||
}
|
||||
private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
|
||||
|
||||
@Override
|
||||
public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (actionMode != null) {
|
||||
handleMediaMultiSelectClick(mediaRecord);
|
||||
} else {
|
||||
handleMediaPreviewClick(mediaRecord);
|
||||
}
|
||||
}
|
||||
MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
|
||||
super(fragmentManager);
|
||||
}
|
||||
|
||||
private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
MediaGalleryAdapter adapter = getListAdapter();
|
||||
|
||||
adapter.toggleSelection(mediaRecord);
|
||||
if (adapter.getSelectedMediaCount() == 0) {
|
||||
actionMode.finish();
|
||||
} else {
|
||||
actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (mediaRecord.getAttachment().getDataUri() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(context, MediaPreviewActivity.class);
|
||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
|
||||
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
|
||||
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
|
||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
|
||||
|
||||
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (actionMode == null) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
|
||||
recyclerView.getAdapter().notifyDataSetChanged();
|
||||
|
||||
enterMultiSelect();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
|
||||
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||
final Context context = requireContext();
|
||||
|
||||
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
|
||||
Permissions.with(this)
|
||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
||||
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
.onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
||||
.onAllGranted(() -> {
|
||||
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
|
||||
context,
|
||||
R.string.MediaOverviewActivity_collecting_attachments,
|
||||
R.string.please_wait) {
|
||||
@Override
|
||||
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
|
||||
List<SaveAttachmentTask.Attachment> attachments = new LinkedList<>();
|
||||
|
||||
for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
|
||||
if (mediaRecord.getAttachment().getDataUri() != null) {
|
||||
attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
|
||||
mediaRecord.getContentType(),
|
||||
mediaRecord.getDate(),
|
||||
mediaRecord.getAttachment().getFileName()));
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) {
|
||||
super.onPostExecute(attachments);
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
|
||||
saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
|
||||
attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
|
||||
actionMode.finish();
|
||||
boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
|
||||
if (containsIncoming) {
|
||||
sendMediaSavedNotificationIfNeeded();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
})
|
||||
.execute();
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMediaSavedNotificationIfNeeded() {
|
||||
if (recipient.isGroupRecipient()) return;
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||
MessageSender.send(message, recipient.getAddress());
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||
int recordCount = mediaRecords.size();
|
||||
|
||||
DeleteMediaDialog.show(
|
||||
requireContext(),
|
||||
recordCount,
|
||||
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
|
||||
requireContext(),
|
||||
R.string.MediaOverviewActivity_Media_delete_progress_title,
|
||||
R.string.MediaOverviewActivity_Media_delete_progress_message) {
|
||||
@Override
|
||||
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
|
||||
if (records == null || records.length == 0) {
|
||||
return null;
|
||||
}
|
||||
public Fragment getItem(int position) {
|
||||
Fragment fragment;
|
||||
|
||||
for (MediaDatabase.MediaRecord record : records) {
|
||||
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
|
||||
}
|
||||
return null;
|
||||
if (position == 0) fragment = new MediaOverviewGalleryFragment();
|
||||
else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
|
||||
else throw new AssertionError();
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
|
||||
args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
|
||||
|
||||
fragment.setArguments(args);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
|
||||
}
|
||||
|
||||
private void handleSelectAllMedia() {
|
||||
getListAdapter().selectAllMedia();
|
||||
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
|
||||
}
|
||||
|
||||
private MediaGalleryAdapter getListAdapter() {
|
||||
return (MediaGalleryAdapter) recyclerView.getAdapter();
|
||||
}
|
||||
|
||||
private void enterMultiSelect() {
|
||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
|
||||
((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
|
||||
}
|
||||
|
||||
private class ActionModeCallback implements ActionMode.Callback {
|
||||
|
||||
private int originalStatusBarColor;
|
||||
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
|
||||
mode.setTitle("1");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
Window window = getActivity().getWindow();
|
||||
originalStatusBarColor = window.getStatusBarColor();
|
||||
window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar));
|
||||
@Override
|
||||
public int getCount() {
|
||||
return 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
|
||||
switch (menuItem.getItemId()) {
|
||||
case R.id.save:
|
||||
handleSaveMedia(getListAdapter().getSelectedMedia());
|
||||
return true;
|
||||
case R.id.delete:
|
||||
handleDeleteMedia(getListAdapter().getSelectedMedia());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.select_all:
|
||||
handleSelectAllMedia();
|
||||
return true;
|
||||
@Override
|
||||
public CharSequence getPageTitle(int position) {
|
||||
if (position == 0) return getString(R.string.MediaOverviewActivity_Media);
|
||||
else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
|
||||
else throw new AssertionError();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {
|
||||
actionMode = null;
|
||||
getListAdapter().clearSelection();
|
||||
((MediaOverviewActivity) getActivity()).onExitMultiSelect();
|
||||
public static abstract class MediaOverviewFragment<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
getActivity().getWindow().setStatusBarColor(originalStatusBarColor);
|
||||
public static final String ADDRESS_EXTRA = "address";
|
||||
public static final String LOCALE_EXTRA = "locale_extra";
|
||||
|
||||
protected TextView noMedia;
|
||||
protected Recipient recipient;
|
||||
protected RecyclerView recyclerView;
|
||||
protected Locale locale;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
|
||||
String address = getArguments().getString(ADDRESS_EXTRA);
|
||||
Locale locale = (Locale) getArguments().getSerializable(LOCALE_EXTRA);
|
||||
|
||||
if (address == null) throw new AssertionError();
|
||||
if (locale == null) throw new AssertionError();
|
||||
|
||||
this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
|
||||
this.locale = locale;
|
||||
|
||||
getLoaderManager().initLoader(0, null, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
|
||||
MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
|
||||
|
||||
this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
|
||||
this.noMedia = ViewUtil.findById(view, R.id.no_documents);
|
||||
|
||||
this.recyclerView.setAdapter(adapter);
|
||||
this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
|
||||
this.recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, false, true));
|
||||
this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
|
||||
public static class MediaOverviewGalleryFragment
|
||||
extends MediaOverviewFragment<Cursor>
|
||||
implements MediaGalleryAdapter.ItemClickListener {
|
||||
|
||||
private GridLayoutManager gridManager;
|
||||
private ActionMode actionMode;
|
||||
private ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
|
||||
|
||||
this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
|
||||
this.noMedia = ViewUtil.findById(view, R.id.no_images);
|
||||
this.gridManager = new GridLayoutManager(getContext(), getResources().getInteger(R.integer.media_overview_cols));
|
||||
|
||||
this.recyclerView.setAdapter(new MediaGalleryAdapter(this));
|
||||
this.recyclerView.setLayoutManager(gridManager);
|
||||
this.recyclerView.setHasFixedSize(true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
if (gridManager != null) {
|
||||
this.gridManager = new GridLayoutManager(getContext(), getResources().getInteger(R.integer.media_overview_cols));
|
||||
this.recyclerView.setLayoutManager(gridManager);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull
|
||||
Loader<Cursor> onCreateLoader(int i, Bundle bundle) {
|
||||
return new ThreadMediaLoader(requireContext(), recipient.getAddress(), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
|
||||
List<MediaDatabase.MediaRecord> mediaRecords = new ArrayList<>();
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
do {
|
||||
mediaRecords.add(MediaDatabase.MediaRecord.from(requireContext(), cursor));
|
||||
} while (cursor.moveToNext());
|
||||
}
|
||||
|
||||
MediaGalleryAdapter adapter = getListAdapter();
|
||||
adapter.setItems(mediaRecords);
|
||||
|
||||
noMedia.setVisibility(adapter.getItemCount() > 0 ? View.GONE : View.VISIBLE);
|
||||
requireActivity().invalidateOptionsMenu();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> cursorLoader) {
|
||||
getListAdapter().setItems(new ArrayList<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (actionMode != null) {
|
||||
handleMediaMultiSelectClick(mediaRecord);
|
||||
} else {
|
||||
handleMediaPreviewClick(mediaRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
MediaGalleryAdapter adapter = getListAdapter();
|
||||
|
||||
adapter.toggleSelection(mediaRecord);
|
||||
if (adapter.getSelectedMediaCount() == 0) {
|
||||
actionMode.finish();
|
||||
} else {
|
||||
actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (mediaRecord.getAttachment().getDataUri() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(context, MediaPreviewActivity.class);
|
||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
|
||||
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
|
||||
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
|
||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
|
||||
|
||||
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (actionMode == null) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
|
||||
recyclerView.getAdapter().notifyDataSetChanged();
|
||||
|
||||
enterMultiSelect();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
|
||||
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||
final Context context = requireContext();
|
||||
|
||||
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
|
||||
Permissions.with(this)
|
||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
||||
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
.onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
||||
.onAllGranted(() -> {
|
||||
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
|
||||
context,
|
||||
R.string.MediaOverviewActivity_collecting_attachments,
|
||||
R.string.please_wait) {
|
||||
@Override
|
||||
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
|
||||
List<SaveAttachmentTask.Attachment> attachments = new LinkedList<>();
|
||||
|
||||
for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
|
||||
if (mediaRecord.getAttachment().getDataUri() != null) {
|
||||
attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
|
||||
mediaRecord.getContentType(),
|
||||
mediaRecord.getDate(),
|
||||
mediaRecord.getAttachment().getFileName()));
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) {
|
||||
super.onPostExecute(attachments);
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
|
||||
saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
|
||||
attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
|
||||
actionMode.finish();
|
||||
boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
|
||||
if (containsIncoming) {
|
||||
sendMediaSavedNotificationIfNeeded();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
})
|
||||
.execute();
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMediaSavedNotificationIfNeeded() {
|
||||
if (recipient.isGroupRecipient()) return;
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||
MessageSender.send(message, recipient.getAddress());
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||
int recordCount = mediaRecords.size();
|
||||
|
||||
DeleteMediaDialog.show(
|
||||
requireContext(),
|
||||
recordCount,
|
||||
() ->
|
||||
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(requireContext(),
|
||||
R.string.MediaOverviewActivity_Media_delete_progress_title,
|
||||
R.string.MediaOverviewActivity_Media_delete_progress_message) {
|
||||
@Override
|
||||
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
|
||||
if (records == null || records.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (MediaDatabase.MediaRecord record : records) {
|
||||
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
|
||||
}
|
||||
|
||||
private void handleSelectAllMedia() {
|
||||
getListAdapter().selectAllMedia();
|
||||
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
|
||||
}
|
||||
|
||||
private MediaGalleryAdapter getListAdapter() {
|
||||
return (MediaGalleryAdapter) recyclerView.getAdapter();
|
||||
}
|
||||
|
||||
private void enterMultiSelect() {
|
||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
|
||||
((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
|
||||
}
|
||||
|
||||
private class ActionModeCallback implements ActionMode.Callback {
|
||||
|
||||
private int originalStatusBarColor;
|
||||
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
|
||||
mode.setTitle("1");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
Window window = getActivity().getWindow();
|
||||
originalStatusBarColor = window.getStatusBarColor();
|
||||
window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
|
||||
switch (menuItem.getItemId()) {
|
||||
case R.id.save:
|
||||
handleSaveMedia(getListAdapter().getSelectedMedia());
|
||||
return true;
|
||||
case R.id.delete:
|
||||
handleDeleteMedia(getListAdapter().getSelectedMedia());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.select_all:
|
||||
handleSelectAllMedia();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {
|
||||
actionMode = null;
|
||||
getListAdapter().clearSelection();
|
||||
((MediaOverviewActivity) getActivity()).onExitMultiSelect();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
getActivity().getWindow().setStatusBarColor(originalStatusBarColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
|
||||
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
|
||||
|
||||
this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
|
||||
MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
||||
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
|
||||
this.noMedia = ViewUtil.findById(view, R.id.no_documents);
|
||||
|
||||
this.recyclerView.setAdapter(adapter);
|
||||
this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), RecyclerView.VERTICAL, false));
|
||||
this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull
|
||||
Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
|
||||
((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(data);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
|
||||
this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
||||
((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(null);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ class ProfilePictureView @JvmOverloads constructor(
|
|||
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
|
||||
}
|
||||
|
||||
if (recipient.isClosedGroupRecipient) {
|
||||
if (recipient.isLegacyClosedGroupRecipient) {
|
||||
val members = DatabaseComponent.get(context).groupDatabase()
|
||||
.getGroupMemberAddresses(recipient.address.toGroupString(), true)
|
||||
.sorted()
|
||||
|
|
|
@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
|
|||
|
||||
private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
|
||||
return getItems(contacts, context.getString(R.string.fragment_contact_selection_closed_groups_title)) {
|
||||
it.address.isClosedGroup
|
||||
it.address.isLegacyClosedGroup || it.address.isClosedGroup
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 */ }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -66,6 +66,7 @@ import network.loki.messenger.R
|
|||
import network.loki.messenger.databinding.ActivityConversationV2Binding
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||
|
@ -83,7 +84,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
|
|||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
||||
|
@ -98,6 +98,7 @@ import org.session.libsignal.crypto.MnemonicCodec
|
|||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.ListenableFuture
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.session.libsignal.utilities.hexEncodedPrivateKey
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
|
@ -105,6 +106,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
|||
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
|
||||
import org.thoughtcrime.securesms.audio.AudioRecorder
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
|
||||
import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract
|
||||
import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
|
||||
|
@ -141,7 +144,6 @@ import org.thoughtcrime.securesms.database.ReactionDatabase
|
|||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
|
@ -198,7 +200,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
|
||||
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>,
|
||||
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
|
||||
ConversationMenuHelper.ConversationMenuListener {
|
||||
ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
|
||||
|
||||
private var binding: ActivityConversationV2Binding? = null
|
||||
|
||||
|
@ -213,7 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
@Inject lateinit var smsDb: SmsDatabase
|
||||
@Inject lateinit var mmsDb: MmsDatabase
|
||||
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
|
||||
@Inject lateinit var storage: Storage
|
||||
@Inject lateinit var storage: StorageProtocol
|
||||
@Inject lateinit var reactionDb: ReactionDatabase
|
||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||
|
||||
|
@ -224,6 +226,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
}
|
||||
}
|
||||
|
||||
private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result ->
|
||||
if (result is ConversationSettingsActivityResult.SearchConversation) {
|
||||
// open search
|
||||
binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView()
|
||||
}
|
||||
}
|
||||
|
||||
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
||||
private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
|
||||
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
||||
|
@ -238,7 +247,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
val sessionId = SessionId(it.serialize())
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1))
|
||||
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
|
||||
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
|
||||
storage.getOrCreateBlindedIdMapping(sessionId.hexString(), openGroup.server, openGroup.publicKey).sessionId?.let {
|
||||
fromSerialized(it)
|
||||
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
|
||||
} else {
|
||||
|
@ -361,7 +370,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
const val PICK_GIF = 10
|
||||
const val PICK_FROM_LIBRARY = 12
|
||||
const val INVITE_CONTACTS = 124
|
||||
|
||||
const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result
|
||||
}
|
||||
// endregion
|
||||
|
||||
|
@ -414,6 +423,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
updatePlaceholder()
|
||||
setUpBlockedBanner()
|
||||
binding!!.searchBottomBar.setEventListener(this)
|
||||
binding!!.toolbarContent.profilePictureView.setOnClickListener(this)
|
||||
updateSendAfterApprovalText()
|
||||
showOrHideInputIfNeeded()
|
||||
setUpMessageRequestsBar()
|
||||
|
@ -578,7 +588,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
recipient.isLocalNumber -> getString(R.string.note_to_self)
|
||||
else -> recipient.toShortString()
|
||||
}
|
||||
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) {
|
||||
@DimenRes val sizeID: Int = if (viewModel.recipient?.isLegacyClosedGroupRecipient == true) {
|
||||
R.dimen.medium_profile_picture_size
|
||||
} else {
|
||||
R.dimen.small_profile_picture_size
|
||||
|
@ -810,8 +820,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
}
|
||||
|
||||
private fun showOrHideInputIfNeeded() {
|
||||
val recipient = viewModel.recipient
|
||||
if (recipient != null && recipient.isClosedGroupRecipient) {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (recipient.isLegacyClosedGroupRecipient) {
|
||||
val group = groupDb.getGroup(recipient.address.toGroupString()).orNull()
|
||||
val isActive = (group?.isActive == true)
|
||||
binding?.inputBar?.showInput = isActive
|
||||
|
@ -821,8 +831,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
}
|
||||
|
||||
private fun setUpMessageRequestsBar() {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread()
|
||||
binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread()
|
||||
binding?.sendAcceptsTextView?.setText(
|
||||
if (recipient.isClosedGroupRecipient) R.string.message_requests_send_group_notice
|
||||
else R.string.message_requests_send_notice
|
||||
)
|
||||
binding?.acceptMessageRequestButton?.setOnClickListener {
|
||||
acceptMessageRequest()
|
||||
}
|
||||
|
@ -856,11 +871,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
|
||||
private fun isIncomingMessageRequestThread(): Boolean {
|
||||
val recipient = viewModel.recipient ?: return false
|
||||
return !recipient.isGroupRecipient &&
|
||||
return !recipient.isLegacyClosedGroupRecipient &&
|
||||
!recipient.isOpenGroupRecipient &&
|
||||
!recipient.isApproved &&
|
||||
!recipient.isLocalNumber &&
|
||||
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() &&
|
||||
threadDb.getMessageCount(viewModel.threadId) > 0
|
||||
(threadDb.getMessageCount(viewModel.threadId) > 0 || recipient.isClosedGroupRecipient)
|
||||
}
|
||||
|
||||
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
||||
|
@ -1116,14 +1132,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
|
||||
}
|
||||
} else if (recipient.isGroupRecipient) {
|
||||
viewModel.openGroup?.let { openGroup ->
|
||||
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
|
||||
} ?: run {
|
||||
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
|
||||
when {
|
||||
recipient.isOpenGroupRecipient -> {
|
||||
viewModel.openGroup?.let { openGroup ->
|
||||
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
|
||||
}
|
||||
}
|
||||
recipient.isLegacyClosedGroupRecipient -> {
|
||||
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
|
||||
}
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
val userCount = viewModel.closedGroupMembers.size
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
|
||||
}
|
||||
}
|
||||
viewModel
|
||||
} else {
|
||||
actionBarBinding.conversationSubtitleView.isVisible = false
|
||||
}
|
||||
|
@ -1140,6 +1164,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
} ?: false
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
if (v === binding?.toolbarContent?.profilePictureView) {
|
||||
// open conversation settings
|
||||
conversationSettingsCallback.launch(viewModel.threadId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun block(deleteThread: Boolean) {
|
||||
showSessionDialog {
|
||||
title(R.string.RecipientPreferenceActivity_block_this_contact_question)
|
||||
|
@ -1174,8 +1205,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// TODO: don't need to allow new closed group check here, removed in new disappearing messages
|
||||
override fun showExpiringMessagesDialog(thread: Recipient) {
|
||||
if (thread.isClosedGroupRecipient) {
|
||||
if (thread.isLegacyClosedGroupRecipient) {
|
||||
val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
|
||||
if (group?.isActive == false) { return }
|
||||
}
|
||||
|
|
|
@ -12,14 +12,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.libsession_util.util.GroupMember
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import java.util.UUID
|
||||
|
@ -28,7 +29,7 @@ class ConversationViewModel(
|
|||
val threadId: Long,
|
||||
val edKeyPair: KeyPair?,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
private val storage: StorageProtocol
|
||||
) : ViewModel() {
|
||||
|
||||
val showSendAfterApprovalText: Boolean
|
||||
|
@ -58,19 +59,27 @@ class ConversationViewModel(
|
|||
val openGroup: OpenGroup?
|
||||
get() = _openGroup.value
|
||||
|
||||
val closedGroupMembers: List<GroupMember>
|
||||
get() {
|
||||
val recipient = recipient ?: return emptyList()
|
||||
if (!recipient.isClosedGroupRecipient) return emptyList()
|
||||
return storage.getMembers(recipient.address.serialize())
|
||||
}
|
||||
|
||||
|
||||
val serverCapabilities: List<String>
|
||||
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
||||
|
||||
val blindedPublicKey: String?
|
||||
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
|
||||
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString()
|
||||
}
|
||||
|
||||
val isMessageRequestThread : Boolean
|
||||
get() {
|
||||
val recipient = recipient ?: return false
|
||||
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
|
||||
return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isOpenGroupRecipient && !recipient.isApproved
|
||||
}
|
||||
|
||||
val canReactToMessages: Boolean
|
||||
|
@ -186,7 +195,8 @@ class ConversationViewModel(
|
|||
}
|
||||
|
||||
fun declineMessageRequest() {
|
||||
repository.declineMessageRequest(threadId)
|
||||
val recipient = recipient ?: return
|
||||
repository.declineMessageRequest(threadId, recipient)
|
||||
}
|
||||
|
||||
private fun showMessage(message: String) {
|
||||
|
@ -228,7 +238,7 @@ class ConversationViewModel(
|
|||
@Assisted private val threadId: Long,
|
||||
@Assisted private val edKeyPair: KeyPair?,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
private val storage: StorageProtocol
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
|
|
|
@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
|
|||
if (!this::recipient.isInitialized) {
|
||||
return dismiss()
|
||||
}
|
||||
if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
|
||||
if (recipient.isLocalNumber) {
|
||||
binding.deleteForEveryoneTextView.text =
|
||||
getString(R.string.delete_message_for_my_devices)
|
||||
} else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
|
||||
binding.deleteForEveryoneTextView.text =
|
||||
resources.getString(R.string.delete_message_for_me_and_recipient, contact)
|
||||
}
|
||||
binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient
|
||||
binding.deleteForEveryoneTextView.isVisible = !recipient.isLegacyClosedGroupRecipient
|
||||
binding.deleteForMeTextView.setOnClickListener(this)
|
||||
binding.deleteForEveryoneTextView.setOnClickListener(this)
|
||||
binding.cancelTextView.setOnClickListener(this)
|
||||
|
|
|
@ -55,6 +55,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
|
@ -81,7 +82,7 @@ import javax.inject.Inject
|
|||
class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var storage: Storage
|
||||
lateinit var storage: StorageProtocol
|
||||
|
||||
private val viewModel: MessageDetailsViewModel by viewModels()
|
||||
|
||||
|
|
|
@ -9,44 +9,53 @@ import android.text.style.StyleSpan
|
|||
import androidx.fragment.app.DialogFragment
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.createSessionDialog
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Shown when receiving media from a contact for the first time, to confirm that
|
||||
* they are to be trusted and files sent by them are to be downloaded. */
|
||||
@AndroidEntryPoint
|
||||
class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
|
||||
class AutoDownloadDialog(private val threadRecipient: Recipient,
|
||||
private val databaseAttachment: DatabaseAttachment
|
||||
) : DialogFragment() {
|
||||
|
||||
@Inject lateinit var storage: StorageProtocol
|
||||
@Inject lateinit var contactDB: SessionContactDatabase
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||
val sessionID = recipient.address.toString()
|
||||
val contact = contactDB.getContactWithSessionID(sessionID)
|
||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||
title(resources.getString(R.string.dialog_download_title, name))
|
||||
val threadId = storage.getThreadId(threadRecipient) ?: run {
|
||||
dismiss()
|
||||
return@createSessionDialog
|
||||
}
|
||||
|
||||
val explanation = resources.getString(R.string.dialog_download_explanation, name)
|
||||
val displayName = when {
|
||||
threadRecipient.isOpenGroupRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN"
|
||||
threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN"
|
||||
threadRecipient.isClosedGroupRecipient -> threadRecipient.name ?: "UNKNOWN"
|
||||
else -> storage.getContactWithSessionID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN"
|
||||
}
|
||||
title(resources.getString(R.string.dialog_auto_download_title))
|
||||
|
||||
val explanation = resources.getString(R.string.dialog_auto_download_explanation, displayName)
|
||||
val spannable = SpannableStringBuilder(explanation)
|
||||
val startIndex = explanation.indexOf(name)
|
||||
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
val startIndex = explanation.indexOf(displayName)
|
||||
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
text(spannable)
|
||||
|
||||
button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() }
|
||||
cancelButton { dismiss() }
|
||||
button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) {
|
||||
setAutoDownload(true)
|
||||
}
|
||||
cancelButton {
|
||||
setAutoDownload(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun trust() {
|
||||
val sessionID = recipient.address.toString()
|
||||
val contact = contactDB.getContactWithSessionID(sessionID) ?: return
|
||||
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
|
||||
contactDB.setContactIsTrusted(contact, true, threadID)
|
||||
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
|
||||
dismiss()
|
||||
private fun setAutoDownload(shouldDownload: Boolean) {
|
||||
storage.setAutoDownloadAttachments(threadRecipient, shouldDownload)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
|
@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
|||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
||||
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
|
||||
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString()
|
||||
fun userCanDeleteSelectedItems(): Boolean {
|
||||
val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
|
||||
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
|
||||
|
|
|
@ -38,8 +38,8 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
|||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
|
||||
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
|
||||
import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity
|
||||
import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity.Companion.groupIDKey
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.service.WebRtcCallService
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
|
@ -63,7 +63,7 @@ object ConversationMenuHelper {
|
|||
// Base menu (options that should always be present)
|
||||
inflater.inflate(R.menu.menu_conversation, menu)
|
||||
// Expiring messages
|
||||
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) {
|
||||
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient) && !thread.isBlocked) {
|
||||
if (thread.expireMessages > 0) {
|
||||
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
|
||||
val item = menu.findItem(R.id.menu_expiring_messages)
|
||||
|
@ -92,7 +92,7 @@ object ConversationMenuHelper {
|
|||
}
|
||||
}
|
||||
// Closed group menu (options that should only be present in closed groups)
|
||||
if (thread.isClosedGroupRecipient) {
|
||||
if (thread.isLegacyClosedGroupRecipient) {
|
||||
inflater.inflate(R.menu.menu_conversation_closed_group, menu)
|
||||
}
|
||||
// Open group menu
|
||||
|
@ -280,15 +280,15 @@ object ConversationMenuHelper {
|
|||
}
|
||||
|
||||
private fun editClosedGroup(context: Context, thread: Recipient) {
|
||||
if (!thread.isClosedGroupRecipient) { return }
|
||||
val intent = Intent(context, EditClosedGroupActivity::class.java)
|
||||
if (!thread.isLegacyClosedGroupRecipient) { return }
|
||||
val intent = Intent(context, EditLegacyClosedGroupActivity::class.java)
|
||||
val groupID: String = thread.address.toGroupString()
|
||||
intent.putExtra(groupIDKey, groupID)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun leaveClosedGroup(context: Context, thread: Recipient) {
|
||||
if (!thread.isClosedGroupRecipient) { return }
|
||||
if (!thread.isLegacyClosedGroupRecipient) { return }
|
||||
|
||||
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
|
||||
val admins = group.admins
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -64,7 +64,6 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
glide: GlideRequests = GlideApp.with(this),
|
||||
thread: Recipient,
|
||||
searchQuery: String? = null,
|
||||
contactIsTrusted: Boolean = true,
|
||||
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
||||
suppressThumbnails: Boolean = false
|
||||
) {
|
||||
|
@ -74,8 +73,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
binding.contentParent.mainColor = color
|
||||
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
|
||||
|
||||
val onlyBodyMessage = message is SmsMessageRecord
|
||||
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
|
||||
val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
|
||||
val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
|
||||
val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
|
||||
|
||||
// reset visibilities / containers
|
||||
onContentClick.clear()
|
||||
|
@ -88,7 +88,6 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
binding.bodyTextView.isVisible = false
|
||||
binding.quoteView.root.isVisible = false
|
||||
binding.linkPreviewView.root.isVisible = false
|
||||
binding.untrustedView.root.isVisible = false
|
||||
binding.voiceMessageView.root.isVisible = false
|
||||
binding.documentView.root.isVisible = false
|
||||
binding.albumThumbnailView.root.isVisible = false
|
||||
|
@ -103,9 +102,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
binding.bodyTextView.text = null
|
||||
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
|
||||
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
|
||||
binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
|
||||
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
|
||||
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
|
||||
binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
|
||||
binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null
|
||||
binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null
|
||||
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
|
||||
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
|
||||
|
||||
|
@ -151,6 +150,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
}
|
||||
|
||||
when {
|
||||
// LINK PREVIEW
|
||||
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
|
||||
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
|
||||
|
@ -158,10 +158,11 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
// When in a link preview ensure the bodyTextView can expand to the full width
|
||||
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
|
||||
}
|
||||
// AUDIO
|
||||
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
|
||||
hideBody = true
|
||||
// Audio attachment
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
|
||||
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
|
||||
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
|
||||
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
|
@ -170,26 +171,38 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
|
||||
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
|
||||
} else {
|
||||
// TODO: move this out to its own area
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
hideBody = true
|
||||
(message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||
binding.pendingAttachmentView.root.bind(
|
||||
PendingAttachmentView.AttachmentType.AUDIO,
|
||||
getTextColor(context,message),
|
||||
attachment
|
||||
)
|
||||
onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// DOCUMENT
|
||||
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
|
||||
hideBody = true
|
||||
hideBody = true // TODO: check if this is still the logic we want
|
||||
// Document attachment
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
||||
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
|
||||
binding.documentView.root.bind(message, getTextColor(context, message))
|
||||
} else {
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
hideBody = true
|
||||
(message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||
binding.pendingAttachmentView.root.bind(
|
||||
PendingAttachmentView.AttachmentType.DOCUMENT,
|
||||
getTextColor(context,message),
|
||||
attachment
|
||||
)
|
||||
onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// IMAGE / VIDEO
|
||||
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
|
||||
/*
|
||||
* Images / Video attachment
|
||||
*/
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
|
||||
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
|
||||
// bind after add view because views are inflated and calculated during bind
|
||||
binding.albumThumbnailView.root.bind(
|
||||
|
@ -207,13 +220,22 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
} else {
|
||||
hideBody = true
|
||||
binding.albumThumbnailView.root.clearViews()
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment
|
||||
firstAttachment?.let { attachment ->
|
||||
binding.pendingAttachmentView.root.bind(
|
||||
PendingAttachmentView.AttachmentType.IMAGE,
|
||||
getTextColor(context,message),
|
||||
attachment
|
||||
)
|
||||
onContentClick.add {
|
||||
binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.isOpenGroupInvitation -> {
|
||||
hideBody = true
|
||||
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
||||
binding.openGroupInvitationView.root.bind(message, getTextColor(context, message))
|
||||
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
|
||||
}
|
||||
}
|
||||
|
@ -250,7 +272,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||
fun recycle() {
|
||||
arrayOf(
|
||||
binding.deletedMessageView.root,
|
||||
binding.untrustedView.root,
|
||||
binding.pendingAttachmentView.root,
|
||||
binding.voiceMessageView.root,
|
||||
binding.openGroupInvitationView.root,
|
||||
binding.documentView.root,
|
||||
|
|
|
@ -267,7 +267,6 @@ class VisibleMessageView : LinearLayout {
|
|||
glide,
|
||||
thread,
|
||||
searchQuery,
|
||||
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
|
||||
onAttachmentNeedsDownload
|
||||
)
|
||||
binding.messageContentView.root.delegate = delegate
|
||||
|
|
|
@ -11,23 +11,31 @@ object MentionManagerUtilities {
|
|||
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
|
||||
val result = mutableSetOf<String>()
|
||||
val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return
|
||||
if (recipient.address.isClosedGroup) {
|
||||
val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
|
||||
result.addAll(members)
|
||||
} else {
|
||||
val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
|
||||
var record: MessageRecord? = reader.next
|
||||
while (record != null) {
|
||||
result.add(record.individualRecipient.address.serialize())
|
||||
try {
|
||||
record = reader.next
|
||||
} catch (exception: Exception) {
|
||||
record = null
|
||||
}
|
||||
val storage = DatabaseComponent.get(context).storage()
|
||||
when {
|
||||
recipient.address.isLegacyClosedGroup -> {
|
||||
val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
|
||||
result.addAll(members)
|
||||
}
|
||||
recipient.address.isClosedGroup -> {
|
||||
val members = storage.getMembers(recipient.address.serialize())
|
||||
result.addAll(members.map { it.sessionId })
|
||||
}
|
||||
recipient.address.isOpenGroup -> {
|
||||
val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
|
||||
var record: MessageRecord? = reader.next
|
||||
while (record != null) {
|
||||
result.add(record.individualRecipient.address.serialize())
|
||||
try {
|
||||
record = reader.next
|
||||
} catch (exception: Exception) {
|
||||
record = null
|
||||
}
|
||||
}
|
||||
reader.close()
|
||||
result.add(TextSecurePreferences.getLocalNumber(context)!!)
|
||||
}
|
||||
reader.close()
|
||||
result.add(TextSecurePreferences.getLocalNumber(context)!!)
|
||||
}
|
||||
MentionsManager.userPublicKeyCache[threadID] = result
|
||||
}
|
||||
|
|
|
@ -966,10 +966,6 @@ public class AttachmentDatabase extends Database {
|
|||
|
||||
@SuppressLint("NewApi")
|
||||
private ThumbnailData generateVideoThumbnail(AttachmentId attachmentId) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
Log.w(TAG, "Video thumbnails not supported...");
|
||||
return null;
|
||||
}
|
||||
|
||||
DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA);
|
||||
|
||||
|
|
|
@ -4,6 +4,9 @@ import android.content.Context
|
|||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.database.getBlobOrNull
|
||||
import androidx.core.database.getLongOrNull
|
||||
import androidx.sqlite.db.transaction
|
||||
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
|
||||
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
|
||||
|
@ -20,6 +23,11 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
|
|||
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
|
||||
|
||||
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
|
||||
private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?"
|
||||
|
||||
val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name
|
||||
val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name
|
||||
val MEMBER_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name
|
||||
}
|
||||
|
||||
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
|
||||
|
@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
|
|||
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
|
||||
}
|
||||
|
||||
fun deleteGroupConfigs(closedGroupId: SessionId) {
|
||||
val db = writableDatabase
|
||||
db.transaction {
|
||||
val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT)
|
||||
db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE,
|
||||
arrayOf(variants, closedGroupId.hexString())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) {
|
||||
val db = writableDatabase
|
||||
db.transaction {
|
||||
val keyContent = contentValuesOf(
|
||||
VARIANT to KEYS_VARIANT,
|
||||
PUBKEY to publicKey,
|
||||
DATA to keysConfig,
|
||||
TIMESTAMP to timestamp
|
||||
)
|
||||
db.insertOrUpdate(TABLE_NAME, keyContent, VARIANT_AND_PUBKEY_WHERE,
|
||||
arrayOf(KEYS_VARIANT, publicKey)
|
||||
)
|
||||
val infoContent = contentValuesOf(
|
||||
VARIANT to INFO_VARIANT,
|
||||
PUBKEY to publicKey,
|
||||
DATA to infoConfig,
|
||||
TIMESTAMP to timestamp
|
||||
)
|
||||
db.insertOrUpdate(TABLE_NAME, infoContent, VARIANT_AND_PUBKEY_WHERE,
|
||||
arrayOf(INFO_VARIANT, publicKey)
|
||||
)
|
||||
val memberContent = contentValuesOf(
|
||||
VARIANT to MEMBER_VARIANT,
|
||||
PUBKEY to publicKey,
|
||||
DATA to memberConfig,
|
||||
TIMESTAMP to timestamp
|
||||
)
|
||||
db.insertOrUpdate(TABLE_NAME, memberContent, VARIANT_AND_PUBKEY_WHERE,
|
||||
arrayOf(MEMBER_VARIANT, publicKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
|
||||
val db = readableDatabase
|
||||
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
|
||||
|
|
|
@ -44,7 +44,8 @@ public class MediaDatabase extends Database {
|
|||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.LINK_PREVIEWS + " "
|
||||
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
|
||||
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
|
||||
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
|
||||
|
@ -52,7 +53,8 @@ public class MediaDatabase extends Database {
|
|||
+ " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
|
||||
+ AttachmentDatabase.DATA + " IS NOT NULL AND "
|
||||
+ AttachmentDatabase.QUOTE + " = 0 AND "
|
||||
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL "
|
||||
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL AND "
|
||||
+ MmsDatabase.LINK_PREVIEWS + " IS NULL "
|
||||
+ "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC";
|
||||
|
||||
private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'");
|
||||
|
|
|
@ -826,23 +826,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||
}
|
||||
}
|
||||
|
||||
private fun deleteQuotedFromMessages(toDeleteRecords: List<MessageRecord>) {
|
||||
if (toDeleteRecords.isEmpty()) return
|
||||
val queryBuilder = StringBuilder()
|
||||
for (i in toDeleteRecords.indices) {
|
||||
queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId())
|
||||
if (i + 1 < toDeleteRecords.size) {
|
||||
queryBuilder.append(" OR ")
|
||||
}
|
||||
}
|
||||
val query = queryBuilder.toString()
|
||||
val db = databaseHelper.writableDatabase
|
||||
val values = ContentValues(2)
|
||||
values.put(QUOTE_MISSING, 1)
|
||||
values.put(QUOTE_AUTHOR, "")
|
||||
db!!.update(TABLE_NAME, values, query, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the messages in single queries where possible
|
||||
* @param messageIds a String array representation of regularly Long types representing message IDs
|
||||
|
@ -926,6 +909,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||
deleteThreads(setOf(threadId))
|
||||
}
|
||||
|
||||
fun deleteMediaFor(threadId: Long, fromUser: String? = null) {
|
||||
val db = databaseHelper.writableDatabase
|
||||
val whereString =
|
||||
if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL"
|
||||
else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL"
|
||||
val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser)
|
||||
var cursor: Cursor? = null
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null)
|
||||
val toDeleteStringMessageIds = mutableListOf<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
|
||||
}
|
||||
// TODO: this can probably be optimized out,
|
||||
// currently attachmentDB uses MmsID not threadID which makes it difficult to delete
|
||||
// and clean up on threadID alone
|
||||
toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
|
||||
deleteMessages(sublist.toTypedArray())
|
||||
}
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
val threadDb = get(context).threadDatabase()
|
||||
threadDb.update(threadId, false, false)
|
||||
notifyConversationListeners(threadId)
|
||||
notifyStickerListeners()
|
||||
notifyStickerPackListeners()
|
||||
}
|
||||
|
||||
fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation
|
||||
val db = databaseHelper.writableDatabase
|
||||
var cursor: Cursor? = null
|
||||
val whereString = "$THREAD_ID = ? AND $ADDRESS = ?"
|
||||
try {
|
||||
cursor =
|
||||
db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null)
|
||||
val toDeleteStringMessageIds = mutableListOf<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
|
||||
}
|
||||
// TODO: this can probably be optimized out,
|
||||
// currently attachmentDB uses MmsID not threadID which makes it difficult to delete
|
||||
// and clean up on threadID alone
|
||||
toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
|
||||
deleteMessages(sublist.toTypedArray())
|
||||
}
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
val threadDb = get(context).threadDatabase()
|
||||
threadDb.update(threadId, false, true)
|
||||
notifyConversationListeners(threadId)
|
||||
notifyStickerListeners()
|
||||
notifyStickerPackListeners()
|
||||
}
|
||||
|
||||
private fun getSerializedSharedContacts(
|
||||
insertedAttachmentIds: Map<Attachment?, AttachmentId?>,
|
||||
contacts: List<Contact?>
|
||||
|
@ -1069,7 +1108,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||
return false
|
||||
}
|
||||
|
||||
/*package*/
|
||||
private fun deleteThreads(threadIds: Set<Long>) {
|
||||
val db = databaseHelper.writableDatabase
|
||||
val where = StringBuilder()
|
||||
|
|
|
@ -64,13 +64,14 @@ public class RecipientDatabase extends Database {
|
|||
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
|
||||
private static final String WRAPPER_HASH = "wrapper_hash";
|
||||
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
|
||||
private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference
|
||||
|
||||
private static final String[] RECIPIENT_PROJECTION = new String[] {
|
||||
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
|
||||
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
|
||||
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
|
||||
UNIDENTIFIED_ACCESS_MODE,
|
||||
FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
|
||||
UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH,
|
||||
BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD,
|
||||
};
|
||||
|
||||
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
|
||||
|
@ -109,6 +110,17 @@ public class RecipientDatabase extends Database {
|
|||
"ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;";
|
||||
}
|
||||
|
||||
public static String getCreateAutoDownloadCommand() {
|
||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + AUTO_DOWNLOAD + " INTEGER DEFAULT -1;";
|
||||
}
|
||||
|
||||
public static String getUpdateAutoDownloadValuesCommand() {
|
||||
return "UPDATE "+TABLE_NAME+" SET "+AUTO_DOWNLOAD+" = 1 "+
|
||||
"WHERE "+ADDRESS+" IN (SELECT "+SessionContactDatabase.sessionContactTable+"."+SessionContactDatabase.sessionID+" "+
|
||||
"FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))";
|
||||
}
|
||||
|
||||
public static String getCreateApprovedCommand() {
|
||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
|
||||
|
@ -178,30 +190,31 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
|
||||
Optional<RecipientSettings> getRecipientSettings(@NonNull Cursor cursor) {
|
||||
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
|
||||
boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
|
||||
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
|
||||
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
|
||||
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
|
||||
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
|
||||
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
||||
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
||||
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
|
||||
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
|
||||
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
|
||||
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
|
||||
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
|
||||
String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
|
||||
String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
|
||||
String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
|
||||
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
|
||||
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
|
||||
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
|
||||
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR));
|
||||
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
|
||||
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
|
||||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
|
||||
boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
|
||||
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
|
||||
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
|
||||
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
|
||||
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
|
||||
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
||||
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
||||
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
|
||||
boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1;
|
||||
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
|
||||
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
|
||||
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
|
||||
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
|
||||
String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
|
||||
String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
|
||||
String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
|
||||
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
|
||||
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
|
||||
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
|
||||
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR));
|
||||
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
|
||||
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
|
||||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
|
||||
boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1;
|
||||
|
||||
|
@ -225,7 +238,7 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
|
||||
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
|
||||
notifyType,
|
||||
notifyType, autoDownloadAttachments,
|
||||
Recipient.VibrateState.fromId(messageVibrateState),
|
||||
Recipient.VibrateState.fromId(callVibrateState),
|
||||
Util.uri(messageRingtone), Util.uri(callRingtone),
|
||||
|
@ -238,6 +251,22 @@ public class RecipientDatabase extends Database {
|
|||
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
|
||||
}
|
||||
|
||||
public boolean isAutoDownloadFlagSet(Recipient recipient) {
|
||||
SQLiteDatabase db = getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null);
|
||||
boolean flagUnset = false;
|
||||
try {
|
||||
if (cursor.moveToFirst()) {
|
||||
// flag isn't set if it is -1
|
||||
flagUnset = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == -1;
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
// negate result (is flag set)
|
||||
return !flagUnset;
|
||||
}
|
||||
|
||||
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(COLOR, color.serialize());
|
||||
|
@ -313,6 +342,21 @@ public class RecipientDatabase extends Database {
|
|||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) {
|
||||
SQLiteDatabase db = getWritableDatabase();
|
||||
db.beginTransaction();
|
||||
try {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0);
|
||||
db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()});
|
||||
recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments);
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setMuted(@NonNull Recipient recipient, long until) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MUTE_UNTIL, until);
|
||||
|
|
|
@ -3,17 +3,16 @@ package org.thoughtcrime.securesms.database
|
|||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
|
||||
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||
|
||||
companion object {
|
||||
private const val sessionContactTable = "session_contact_database"
|
||||
const val sessionContactTable = "session_contact_database"
|
||||
const val sessionID = "session_id"
|
||||
const val name = "name"
|
||||
const val nickname = "nickname"
|
||||
|
@ -74,23 +73,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
|
|||
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
|
||||
}
|
||||
contentValues.put(threadID, contact.threadID)
|
||||
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
|
||||
database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID ))
|
||||
notifyConversationListListeners()
|
||||
}
|
||||
|
||||
fun contactFromCursor(cursor: Cursor): Contact {
|
||||
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
|
||||
val sessionID = cursor.getString(sessionID)
|
||||
val contact = Contact(sessionID)
|
||||
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
|
||||
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
|
||||
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))
|
||||
contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName))
|
||||
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let {
|
||||
contact.name = cursor.getStringOrNull(name)
|
||||
contact.nickname = cursor.getStringOrNull(nickname)
|
||||
contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
|
||||
contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
|
||||
cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
|
||||
contact.profilePictureEncryptionKey = Base64.decode(it)
|
||||
}
|
||||
contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID))
|
||||
contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
|
||||
contact.threadID = cursor.getLong(threadID)
|
||||
return contact
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
|||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
||||
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
|
||||
import org.session.libsession.messaging.jobs.InviteContactsJob
|
||||
import org.session.libsession.messaging.jobs.Job
|
||||
import org.session.libsession.messaging.jobs.MessageReceiveJob
|
||||
import org.session.libsession.messaging.jobs.MessageSendJob
|
||||
|
@ -73,6 +74,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
|||
return result.firstOrNull { job -> job.attachmentID == attachmentID }
|
||||
}
|
||||
|
||||
fun getGroupInviteJob(groupSessionId: String, memberSessionId: String): InviteContactsJob? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(InviteContactsJob.KEY)) { cursor ->
|
||||
jobFromCursor(cursor) as? InviteContactsJob
|
||||
}.firstOrNull { it != null && it.groupSessionId == groupSessionId && it.memberSessionIds.contains(memberSessionId) }
|
||||
}
|
||||
|
||||
fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor ->
|
||||
|
|
|
@ -685,11 +685,16 @@ public class SmsDatabase extends MessagingDatabase {
|
|||
}
|
||||
}
|
||||
|
||||
/*package */void deleteThread(long threadId) {
|
||||
void deleteThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
|
||||
}
|
||||
|
||||
void deleteMessagesFrom(long threadId, String fromUser) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
|
||||
}
|
||||
|
||||
/*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX;
|
||||
import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX;
|
||||
import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX;
|
||||
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
|
||||
|
||||
|
@ -439,32 +439,6 @@ public class ThreadDatabase extends Database {
|
|||
return db.rawQuery(query, null);
|
||||
}
|
||||
|
||||
public int getUnapprovedConversationCount() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
String query = "SELECT COUNT (*) FROM " + TABLE_NAME +
|
||||
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
|
||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
|
||||
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
|
||||
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
|
||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
|
||||
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
|
||||
cursor = db.rawQuery(query, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
return cursor.getInt(0);
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public long getLatestUnapprovedConversationTimestamp() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
@ -503,13 +477,15 @@ public class ThreadDatabase extends Database {
|
|||
}
|
||||
|
||||
public Cursor getApprovedConversationList() {
|
||||
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
|
||||
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+ LEGACY_CLOSED_GROUP_PREFIX +"%') " +
|
||||
"OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
|
||||
"AND " + ARCHIVED + " = 0 ";
|
||||
return getConversationList(where);
|
||||
}
|
||||
|
||||
public Cursor getUnapprovedConversationList() {
|
||||
String where = MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
||||
String where = "("+MESSAGE_COUNT + " != 0 OR "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" LIKE '"+IdPrefix.GROUP.getValue()+"%')" +
|
||||
" AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
|
||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
|
||||
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
|
||||
|
@ -771,6 +747,8 @@ public class ThreadDatabase extends Database {
|
|||
if (shouldDeleteEmptyThread) {
|
||||
deleteThread(threadId);
|
||||
return true;
|
||||
} else {
|
||||
updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -828,8 +806,7 @@ public class ThreadDatabase extends Database {
|
|||
}
|
||||
|
||||
private boolean deleteThreadOnEmpty(long threadId) {
|
||||
Recipient threadRecipient = getRecipientForThreadId(threadId);
|
||||
return threadRecipient != null && !threadRecipient.isOpenGroupRecipient();
|
||||
return false;
|
||||
}
|
||||
|
||||
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
|
||||
|
|
|
@ -89,11 +89,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
private static final int lokiV41 = 62;
|
||||
private static final int lokiV42 = 63;
|
||||
private static final int lokiV43 = 64;
|
||||
|
||||
private static final int lokiV44 = 65;
|
||||
private static final int lokiV45 = 66;
|
||||
|
||||
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
||||
private static final int DATABASE_VERSION = lokiV44;
|
||||
private static final int DATABASE_VERSION = lokiV45;
|
||||
private static final int MIN_DATABASE_VERSION = lokiV7;
|
||||
private static final String CIPHER3_DATABASE_NAME = "signal.db";
|
||||
public static final String DATABASE_NAME = "signal_v4.db";
|
||||
|
@ -360,6 +360,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
|
||||
db.execSQL(RecipientDatabase.getAddWrapperHash());
|
||||
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
|
||||
|
||||
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
|
||||
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -610,6 +613,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV45) {
|
||||
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
|
||||
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.session.libsession.utilities.AppTextSecurePreferences
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
@ -22,8 +28,23 @@ abstract class AppModule {
|
|||
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class ToasterModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideToaster(@ApplicationContext context: Context) = Toaster { stringRes, toastLength, parameters ->
|
||||
val string = context.getString(stringRes, parameters)
|
||||
Toast.makeText(context, string, toastLength).show()
|
||||
}
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AppComponent {
|
||||
fun getPrefs(): TextSecurePreferences
|
||||
}
|
||||
|
||||
fun interface Toaster {
|
||||
fun toast(@StringRes stringRes: Int, toastLength: Int, vararg parameters: Any)
|
||||
}
|
|
@ -1,16 +1,12 @@
|
|||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ServiceComponent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.scopes.ServiceScoped
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.session.libsession.database.CallDataProvider
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.thoughtcrime.securesms.webrtc.CallManager
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
|
||||
import javax.inject.Singleton
|
||||
|
@ -25,7 +21,7 @@ object CallModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) =
|
||||
fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) =
|
||||
CallManager(context, audioManagerCompat, storage)
|
||||
|
||||
}
|
|
@ -2,17 +2,25 @@ package org.thoughtcrime.securesms.dependencies
|
|||
|
||||
import android.content.Context
|
||||
import android.os.Trace
|
||||
import network.loki.messenger.libsession_util.Config
|
||||
import network.loki.messenger.libsession_util.ConfigBase
|
||||
import network.loki.messenger.libsession_util.Contacts
|
||||
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
||||
import network.loki.messenger.libsession_util.GroupInfoConfig
|
||||
import network.loki.messenger.libsession_util.GroupKeysConfig
|
||||
import network.loki.messenger.libsession_util.GroupMembersConfig
|
||||
import network.loki.messenger.libsession_util.UserGroupsConfig
|
||||
import network.loki.messenger.libsession_util.UserProfile
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.ConfigFactoryProtocol
|
||||
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
|
||||
import org.session.libsignal.utilities.Hex
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.database.ConfigDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
|
@ -61,7 +69,7 @@ class ConfigFactory(
|
|||
listeners -= listener
|
||||
}
|
||||
|
||||
private inline fun <T> synchronizedWithLog(lock: Any, body: ()->T): T {
|
||||
private inline fun <T> synchronizedWithLog(lock: Any, body: () -> T): T {
|
||||
Trace.beginSection("synchronizedWithLog")
|
||||
val result = synchronized(lock) {
|
||||
body()
|
||||
|
@ -72,7 +80,11 @@ class ConfigFactory(
|
|||
|
||||
override val user: UserProfile?
|
||||
get() = synchronizedWithLog(userLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (!ConfigBase.isNewConfigEnabled(
|
||||
isConfigForcedOn,
|
||||
SnodeAPI.nowWithOffset
|
||||
)
|
||||
) return null
|
||||
if (_userConfig == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val userDump = configDatabase.retrieveConfigAndHashes(
|
||||
|
@ -92,7 +104,11 @@ class ConfigFactory(
|
|||
|
||||
override val contacts: Contacts?
|
||||
get() = synchronizedWithLog(contactsLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (!ConfigBase.isNewConfigEnabled(
|
||||
isConfigForcedOn,
|
||||
SnodeAPI.nowWithOffset
|
||||
)
|
||||
) return null
|
||||
if (_contacts == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val contactsDump = configDatabase.retrieveConfigAndHashes(
|
||||
|
@ -112,7 +128,11 @@ class ConfigFactory(
|
|||
|
||||
override val convoVolatile: ConversationVolatileConfig?
|
||||
get() = synchronizedWithLog(convoVolatileLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (!ConfigBase.isNewConfigEnabled(
|
||||
isConfigForcedOn,
|
||||
SnodeAPI.nowWithOffset
|
||||
)
|
||||
) return null
|
||||
if (_convoVolatileConfig == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val convoDump = configDatabase.retrieveConfigAndHashes(
|
||||
|
@ -133,7 +153,11 @@ class ConfigFactory(
|
|||
|
||||
override val userGroups: UserGroupsConfig?
|
||||
get() = synchronizedWithLog(userGroupsLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (!ConfigBase.isNewConfigEnabled(
|
||||
isConfigForcedOn,
|
||||
SnodeAPI.nowWithOffset
|
||||
)
|
||||
) return null
|
||||
if (_userGroups == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val userGroupsDump = configDatabase.retrieveConfigAndHashes(
|
||||
|
@ -151,6 +175,86 @@ class ConfigFactory(
|
|||
_userGroups
|
||||
}
|
||||
|
||||
private fun getGroupAuthInfo(groupSessionId: SessionId) = userGroups?.getClosedGroup(groupSessionId.hexString())?.let {
|
||||
it.adminKey to it.authData
|
||||
}
|
||||
|
||||
override fun getGroupInfoConfig(groupSessionId: SessionId): GroupInfoConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) ->
|
||||
// get any potential initial dumps
|
||||
val dump = configDatabase.retrieveConfigAndHashes(
|
||||
ConfigDatabase.INFO_VARIANT,
|
||||
groupSessionId.hexString()
|
||||
) ?: byteArrayOf()
|
||||
|
||||
GroupInfoConfig.newInstance(Hex.fromStringCondensed(groupSessionId.publicKey), sk, dump)
|
||||
}
|
||||
|
||||
override fun getGroupKeysConfig(groupSessionId: SessionId,
|
||||
info: GroupInfoConfig?,
|
||||
members: GroupMembersConfig?,
|
||||
free: Boolean): GroupKeysConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) ->
|
||||
// Get the user info or return early
|
||||
val (userSk, _) = maybeGetUserInfo() ?: return@let null
|
||||
|
||||
// Get the group info or return early
|
||||
val usedInfo = info ?: getGroupInfoConfig(groupSessionId) ?: return@let null
|
||||
|
||||
// Get the group members or return early
|
||||
val usedMembers = members ?: getGroupMemberConfig(groupSessionId) ?: return@let null
|
||||
|
||||
// Get the dump or empty
|
||||
val dump = configDatabase.retrieveConfigAndHashes(
|
||||
ConfigDatabase.KEYS_VARIANT,
|
||||
groupSessionId.hexString()
|
||||
) ?: byteArrayOf()
|
||||
|
||||
// Put it all together
|
||||
val keys = GroupKeysConfig.newInstance(
|
||||
userSk,
|
||||
Hex.fromStringCondensed(groupSessionId.publicKey),
|
||||
sk,
|
||||
dump,
|
||||
usedInfo,
|
||||
usedMembers
|
||||
)
|
||||
if (free) {
|
||||
info?.free()
|
||||
members?.free()
|
||||
}
|
||||
if (usedInfo !== info) usedInfo.free()
|
||||
if (usedMembers !== members) usedMembers.free()
|
||||
keys
|
||||
}
|
||||
|
||||
override fun getGroupMemberConfig(groupSessionId: SessionId): GroupMembersConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, auth) ->
|
||||
// Get initial dump if we have one
|
||||
val dump = configDatabase.retrieveConfigAndHashes(
|
||||
ConfigDatabase.MEMBER_VARIANT,
|
||||
groupSessionId.hexString()
|
||||
) ?: byteArrayOf()
|
||||
|
||||
GroupMembersConfig.newInstance(
|
||||
Hex.fromStringCondensed(groupSessionId.publicKey),
|
||||
sk,
|
||||
dump
|
||||
)
|
||||
}
|
||||
|
||||
override fun constructGroupKeysConfig(
|
||||
groupSessionId: SessionId,
|
||||
info: GroupInfoConfig,
|
||||
members: GroupMembersConfig
|
||||
): GroupKeysConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) ->
|
||||
val (userSk, _) = maybeGetUserInfo() ?: return null
|
||||
GroupKeysConfig.newInstance(
|
||||
userSk,
|
||||
Hex.fromStringCondensed(groupSessionId.publicKey),
|
||||
sk,
|
||||
info = info,
|
||||
members = members
|
||||
)
|
||||
}
|
||||
|
||||
override fun getUserConfigs(): List<ConfigBase> =
|
||||
listOfNotNull(user, contacts, convoVolatile, userGroups)
|
||||
|
||||
|
@ -158,13 +262,23 @@ class ConfigFactory(
|
|||
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
|
||||
val dumped = user?.dump() ?: return
|
||||
val (_, publicKey) = maybeGetUserInfo() ?: return
|
||||
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp)
|
||||
configDatabase.storeConfig(
|
||||
SharedConfigMessage.Kind.USER_PROFILE.name,
|
||||
publicKey,
|
||||
dumped,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
|
||||
val dumped = contacts?.dump() ?: return
|
||||
val (_, publicKey) = maybeGetUserInfo() ?: return
|
||||
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
|
||||
configDatabase.storeConfig(
|
||||
SharedConfigMessage.Kind.CONTACTS.name,
|
||||
publicKey,
|
||||
dumped,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
|
||||
|
@ -181,10 +295,30 @@ class ConfigFactory(
|
|||
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
|
||||
val dumped = userGroups?.dump() ?: return
|
||||
val (_, publicKey) = maybeGetUserInfo() ?: return
|
||||
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp)
|
||||
configDatabase.storeConfig(
|
||||
SharedConfigMessage.Kind.GROUPS.name,
|
||||
publicKey,
|
||||
dumped,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
|
||||
fun persistGroupConfigDump(forConfigObject: ConfigBase, groupSessionId: SessionId, timestamp: Long) = synchronized(userGroupsLock) {
|
||||
val dumped = forConfigObject.dump()
|
||||
val variant = when (forConfigObject) {
|
||||
is GroupMembersConfig -> ConfigDatabase.MEMBER_VARIANT
|
||||
is GroupInfoConfig -> ConfigDatabase.INFO_VARIANT
|
||||
else -> throw Exception("Shouldn't be called")
|
||||
}
|
||||
configDatabase.storeConfig(
|
||||
variant,
|
||||
groupSessionId.hexString(),
|
||||
dumped,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) {
|
||||
try {
|
||||
listeners.forEach { listener ->
|
||||
listener.notifyUpdates(forConfigObject)
|
||||
|
@ -194,6 +328,8 @@ class ConfigFactory(
|
|||
is Contacts -> persistContactsConfigDump(timestamp)
|
||||
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
|
||||
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
|
||||
is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, SessionId.from(forPublicKey!!), timestamp)
|
||||
is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, SessionId.from(forPublicKey!!), timestamp)
|
||||
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -214,23 +350,25 @@ class ConfigFactory(
|
|||
if (openGroupId != null) {
|
||||
val userGroups = userGroups ?: return false
|
||||
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
|
||||
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
|
||||
val openGroup =
|
||||
get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
|
||||
|
||||
// Not handling the `hidden` behaviour for communities so just indicate the existence
|
||||
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
|
||||
}
|
||||
else if (groupPublicKey != null) {
|
||||
} else if (groupPublicKey != null) {
|
||||
val userGroups = userGroups ?: return false
|
||||
|
||||
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
|
||||
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
|
||||
}
|
||||
else if (publicKey == userPublicKey) {
|
||||
return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
|
||||
userGroups.getClosedGroup(groupPublicKey) != null
|
||||
} else {
|
||||
userGroups.getLegacyGroupInfo(groupPublicKey) != null
|
||||
}
|
||||
} else if (publicKey == userPublicKey) {
|
||||
val user = user ?: return false
|
||||
|
||||
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
|
||||
}
|
||||
else if (publicKey != null) {
|
||||
} else if (publicKey != null) {
|
||||
val contacts = contacts ?: return false
|
||||
val targetContact = contacts.get(publicKey) ?: return false
|
||||
|
||||
|
@ -240,12 +378,38 @@ class ConfigFactory(
|
|||
return false
|
||||
}
|
||||
|
||||
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
|
||||
override fun canPerformChange(
|
||||
variant: String,
|
||||
publicKey: String,
|
||||
changeTimestampMs: Long
|
||||
): Boolean {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
|
||||
|
||||
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
|
||||
val lastUpdateTimestampMs =
|
||||
configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
|
||||
|
||||
// Ensure the change occurred after the last config message was handled (minus the buffer period)
|
||||
return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod))
|
||||
return (changeTimestampMs >= (lastUpdateTimestampMs - configChangeBufferPeriod))
|
||||
}
|
||||
|
||||
override fun saveGroupConfigs(
|
||||
groupKeys: GroupKeysConfig,
|
||||
groupInfo: GroupInfoConfig,
|
||||
groupMembers: GroupMembersConfig
|
||||
) {
|
||||
val pubKey = groupInfo.id().hexString()
|
||||
val timestamp = SnodeAPI.nowWithOffset
|
||||
configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp)
|
||||
}
|
||||
|
||||
override fun removeGroup(closedGroupId: SessionId) {
|
||||
val groups = userGroups ?: return
|
||||
groups.eraseClosedGroup(closedGroupId.hexString())
|
||||
configDatabase.deleteGroupConfigs(closedGroupId)
|
||||
}
|
||||
|
||||
override fun scheduleUpdate(destination: Destination) {
|
||||
// there's probably a better way to do this
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -5,6 +5,7 @@ import dagger.hilt.EntryPoint
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.database.*
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
|
|
|
@ -131,8 +131,13 @@ object DatabaseModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
|
||||
val storage = Storage(context,openHelper, configFactory)
|
||||
fun provideStorage(@ApplicationContext context: Context,
|
||||
openHelper: SQLCipherOpenHelper,
|
||||
configFactory: ConfigFactory,
|
||||
threadDatabase: ThreadDatabase,
|
||||
pollerFactory: PollerFactory,
|
||||
toaster: Toaster): Storage {
|
||||
val storage = Storage(context, openHelper, configFactory, pollerFactory, toaster)
|
||||
threadDatabase.setUpdateListener(storage)
|
||||
return storage
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -6,16 +6,24 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
||||
import org.thoughtcrime.securesms.database.ConfigDatabase
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SessionUtilModule {
|
||||
|
||||
const val POLLER_SCOPE = "poller_coroutine_scope"
|
||||
|
||||
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
|
||||
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
|
||||
return edKey.secretKey.asBytes
|
||||
|
@ -33,4 +41,19 @@ object SessionUtilModule {
|
|||
registerListener(context as ConfigFactoryUpdateListener)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Named(POLLER_SCOPE)
|
||||
fun providePollerScope(@ApplicationContext applicationContext: Context): CoroutineScope = GlobalScope
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Provides
|
||||
@Named(POLLER_SCOPE)
|
||||
fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope,
|
||||
@Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher,
|
||||
configFactory: ConfigFactory) = PollerFactory(coroutineScope, dispatcher, configFactory)
|
||||
|
||||
}
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
|||
import network.loki.messenger.libsession_util.ConfigBase
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.GroupRecord
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
|
@ -26,7 +26,7 @@ object ClosedGroupManager {
|
|||
// Notify the PN server
|
||||
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
|
||||
// Stop polling
|
||||
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
|
||||
LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
|
||||
storage.cancelPendingMessageSendJobs(threadId)
|
||||
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
|
||||
if (delete) {
|
||||
|
@ -34,16 +34,9 @@ object ClosedGroupManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean {
|
||||
val groups = userGroups ?: return false
|
||||
if (!group.isClosedGroup) return false
|
||||
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
|
||||
return groups.eraseLegacyGroup(groupPublicKey)
|
||||
}
|
||||
|
||||
fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) {
|
||||
val groups = userGroups ?: return
|
||||
if (!group.isClosedGroup) return
|
||||
if (!group.isLegacyClosedGroup) return
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val threadId = storage.getThreadId(group.encodedId) ?: return
|
||||
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
|
||||
|
|
|
@ -1,47 +1,44 @@
|
|||
package org.thoughtcrime.securesms.groups
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.dependency
|
||||
import com.ramcosta.composedestinations.result.NavResult
|
||||
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
||||
import com.ramcosta.composedestinations.result.ResultRecipient
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import network.loki.messenger.databinding.FragmentCreateGroupBinding
|
||||
import nl.komponents.kovenant.ui.failUi
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Device
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.fadeIn
|
||||
import org.thoughtcrime.securesms.util.fadeOut
|
||||
import javax.inject.Inject
|
||||
import org.thoughtcrime.securesms.groups.compose.CreateGroup
|
||||
import org.thoughtcrime.securesms.groups.compose.CreateGroupNavGraph
|
||||
import org.thoughtcrime.securesms.groups.compose.SelectContacts
|
||||
import org.thoughtcrime.securesms.groups.compose.StateUpdate
|
||||
import org.thoughtcrime.securesms.groups.compose.ViewState
|
||||
import org.thoughtcrime.securesms.groups.destinations.SelectContactsScreenDestination
|
||||
import org.thoughtcrime.securesms.ui.AppTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CreateGroupFragment : Fragment() {
|
||||
|
||||
@Inject
|
||||
lateinit var device: Device
|
||||
|
||||
private lateinit var binding: FragmentCreateGroupBinding
|
||||
private val viewModel: CreateGroupViewModel by viewModels()
|
||||
|
||||
lateinit var delegate: NewConversationDelegate
|
||||
|
||||
|
@ -49,76 +46,91 @@ class CreateGroupFragment : Fragment() {
|
|||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentCreateGroupBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val adapter = SelectContactsAdapter(requireContext(), GlideApp.with(requireContext()))
|
||||
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() }
|
||||
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
|
||||
binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks {
|
||||
override fun onQueryChanged(query: String) {
|
||||
adapter.members = viewModel.filter(query).map { it.address.serialize() }
|
||||
return ComposeView(requireContext()).apply {
|
||||
val getDelegate = { delegate }
|
||||
setContent {
|
||||
AppTheme {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.createGroup,
|
||||
dependenciesContainerBuilder = {
|
||||
dependency(getDelegate)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
|
||||
binding.recyclerView.adapter = adapter
|
||||
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
|
||||
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
|
||||
setDrawable(it)
|
||||
}
|
||||
}
|
||||
binding.recyclerView.addItemDecoration(divider)
|
||||
var isLoading = false
|
||||
binding.createClosedGroupButton.setOnClickListener {
|
||||
if (isLoading) return@setOnClickListener
|
||||
val name = binding.nameEditText.text.trim()
|
||||
if (name.isEmpty()) {
|
||||
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (name.length >= 30) {
|
||||
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
val selectedMembers = adapter.selectedMembers
|
||||
if (selectedMembers.isEmpty()) {
|
||||
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later
|
||||
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
|
||||
isLoading = true
|
||||
binding.loaderContainer.fadeIn()
|
||||
MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
|
||||
binding.loaderContainer.fadeOut()
|
||||
isLoading = false
|
||||
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))
|
||||
openConversationActivity(
|
||||
requireContext(),
|
||||
threadID,
|
||||
Recipient.from(requireContext(), Address.fromSerialized(groupID), false)
|
||||
)
|
||||
delegate.onDialogClosePressed()
|
||||
}.failUi {
|
||||
binding.loaderContainer.fadeOut()
|
||||
isLoading = false
|
||||
Toast.makeText(context, it.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty()
|
||||
binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty()
|
||||
viewModel.recipients.observe(viewLifecycleOwner) { recipients ->
|
||||
adapter.members = recipients.map { it.address.serialize() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) {
|
||||
val intent = Intent(context, ConversationActivityV2::class.java)
|
||||
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
|
||||
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ContactList(val contacts: Set<Contact>) : Parcelable
|
||||
|
||||
@CreateGroupNavGraph(start = true)
|
||||
@Composable
|
||||
@Destination
|
||||
fun CreateGroupScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
resultSelectContact: ResultRecipient<SelectContactsScreenDestination, ContactList>,
|
||||
viewModel: CreateGroupViewModel = hiltViewModel(),
|
||||
getDelegate: () -> NewConversationDelegate
|
||||
) {
|
||||
val viewState by viewModel.viewState.observeAsState(ViewState.DEFAULT)
|
||||
|
||||
resultSelectContact.onNavResult { navResult ->
|
||||
when (navResult) {
|
||||
is NavResult.Value -> {
|
||||
viewModel.updateState(StateUpdate.AddContacts(navResult.value.contacts))
|
||||
}
|
||||
|
||||
is NavResult.Canceled -> { /* do nothing */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
viewState.createdGroup?.let { group ->
|
||||
SideEffect {
|
||||
getDelegate().onDialogClosePressed()
|
||||
val intent = Intent(context, ConversationActivityV2::class.java).apply {
|
||||
putExtra(ConversationActivityV2.ADDRESS, group.address)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
CreateGroup(
|
||||
viewState,
|
||||
viewModel::updateState,
|
||||
onClose = {
|
||||
getDelegate().onDialogClosePressed()
|
||||
},
|
||||
onSelectContact = { navigator.navigate(SelectContactsScreenDestination) },
|
||||
onBack = {
|
||||
getDelegate().onDialogBackPressed()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@CreateGroupNavGraph
|
||||
@Composable
|
||||
@Destination
|
||||
fun SelectContactsScreen(
|
||||
resultNavigator: ResultBackNavigator<ContactList>,
|
||||
viewModel: CreateGroupViewModel = hiltViewModel(),
|
||||
getDelegate: () -> NewConversationDelegate
|
||||
) {
|
||||
|
||||
val viewState by viewModel.viewState.observeAsState(ViewState.DEFAULT)
|
||||
val currentMembers = viewState.members
|
||||
val contacts by viewModel.contacts.observeAsState(initial = emptySet())
|
||||
|
||||
SelectContacts(
|
||||
contacts - currentMembers,
|
||||
onBack = { resultNavigator.navigateBack() },
|
||||
onClose = { getDelegate().onDialogClosePressed() },
|
||||
onContactsSelected = {
|
||||
resultNavigator.navigateBack(ContactList(it))
|
||||
}
|
||||
)
|
||||
}
|
|
@ -3,44 +3,80 @@ package org.thoughtcrime.securesms.groups
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.groups.compose.StateUpdate
|
||||
import org.thoughtcrime.securesms.groups.compose.ViewState
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CreateGroupViewModel @Inject constructor(
|
||||
private val threadDb: ThreadDatabase,
|
||||
private val textSecurePreferences: TextSecurePreferences
|
||||
private val storage: Storage,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _recipients = MutableLiveData<List<Recipient>>()
|
||||
val recipients: LiveData<List<Recipient>> = _recipients
|
||||
private inline fun <reified T> MutableLiveData<T>.update(body: T.() -> T) {
|
||||
this.postValue(body(this.value!!))
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
threadDb.approvedConversationList.use { openCursor ->
|
||||
val reader = threadDb.readerFor(openCursor)
|
||||
val recipients = mutableListOf<Recipient>()
|
||||
while (true) {
|
||||
recipients += reader.next?.recipient ?: break
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
_recipients.value = recipients
|
||||
.filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() }
|
||||
}
|
||||
}
|
||||
private val _viewState = MutableLiveData(ViewState.DEFAULT.copy())
|
||||
|
||||
val viewState: LiveData<ViewState> = _viewState
|
||||
|
||||
fun updateState(stateUpdate: StateUpdate) {
|
||||
when (stateUpdate) {
|
||||
is StateUpdate.AddContacts -> _viewState.update { copy(members = members + stateUpdate.value) }
|
||||
is StateUpdate.Description -> _viewState.update { copy(description = stateUpdate.value) }
|
||||
is StateUpdate.Name -> _viewState.update { copy(name = stateUpdate.value) }
|
||||
is StateUpdate.RemoveContact -> _viewState.update { copy(members = members - stateUpdate.value) }
|
||||
StateUpdate.Create -> { viewModelScope.launch { tryCreateGroup() } }
|
||||
}
|
||||
}
|
||||
|
||||
fun filter(query: String): List<Recipient> {
|
||||
return _recipients.value?.filter {
|
||||
it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true
|
||||
} ?: emptyList()
|
||||
val contacts
|
||||
get() = liveData { emit(storage.getAllContacts()) }
|
||||
|
||||
fun tryCreateGroup() {
|
||||
|
||||
val currentState = _viewState.value!!
|
||||
|
||||
_viewState.postValue(currentState.copy(isLoading = true, error = null))
|
||||
|
||||
val name = currentState.name
|
||||
val description = currentState.description
|
||||
val members = currentState.members.toMutableSet()
|
||||
|
||||
// do some validation
|
||||
// need a name
|
||||
if (name.isEmpty()) {
|
||||
return _viewState.postValue(
|
||||
currentState.copy(isLoading = false, error = R.string.error)
|
||||
)
|
||||
}
|
||||
|
||||
if (members.size <= 1) {
|
||||
_viewState.postValue(
|
||||
currentState.copy(
|
||||
isLoading = false,
|
||||
error = R.string.activity_create_closed_group_not_enough_group_members_error
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// make a group
|
||||
val newGroup = storage.createNewGroup(name, description, members)
|
||||
if (!newGroup.isPresent) {
|
||||
// show a generic couldn't create or something?
|
||||
return _viewState.postValue(currentState.copy(isLoading = false, error = null))
|
||||
} else {
|
||||
return _viewState.postValue(currentState.copy(
|
||||
isLoading = false,
|
||||
error = null,
|
||||
createdGroup = newGroup.get())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,342 +1,50 @@
|
|||
package org.thoughtcrime.securesms.groups
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.activity.compose.setContent
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.navigation.dependency
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.task
|
||||
import nl.komponents.kovenant.ui.failUi
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.ThemeUtil
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.fadeIn
|
||||
import org.thoughtcrime.securesms.util.fadeOut
|
||||
import java.io.IOException
|
||||
import org.thoughtcrime.securesms.groups.compose.EditGroupInviteViewModel
|
||||
import org.thoughtcrime.securesms.groups.compose.EditGroupViewModel
|
||||
import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupScreenDestination
|
||||
import org.thoughtcrime.securesms.ui.AppTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var groupConfigFactory: ConfigFactory
|
||||
@Inject
|
||||
lateinit var storage: Storage
|
||||
|
||||
private val originalMembers = HashSet<String>()
|
||||
private val zombies = HashSet<String>()
|
||||
private val members = HashSet<String>()
|
||||
private val allMembers: Set<String>
|
||||
get() {
|
||||
return members + zombies
|
||||
}
|
||||
private var hasNameChanged = false
|
||||
private var isSelfAdmin = false
|
||||
private var isLoading = false
|
||||
set(newValue) { field = newValue; invalidateOptionsMenu() }
|
||||
|
||||
private lateinit var groupID: String
|
||||
private lateinit var originalName: String
|
||||
private lateinit var name: String
|
||||
|
||||
private var isEditingName = false
|
||||
set(value) {
|
||||
if (field == value) return
|
||||
field = value
|
||||
handleIsEditingNameChanged()
|
||||
}
|
||||
|
||||
private val memberListAdapter by lazy {
|
||||
if (isSelfAdmin)
|
||||
EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick)
|
||||
else
|
||||
EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin)
|
||||
}
|
||||
|
||||
private lateinit var mainContentContainer: LinearLayout
|
||||
private lateinit var cntGroupNameEdit: LinearLayout
|
||||
private lateinit var cntGroupNameDisplay: LinearLayout
|
||||
private lateinit var edtGroupName: EditText
|
||||
private lateinit var emptyStateContainer: LinearLayout
|
||||
private lateinit var lblGroupNameDisplay: TextView
|
||||
private lateinit var loaderContainer: View
|
||||
class EditClosedGroupActivity: PassphraseRequiredActionBarActivity() {
|
||||
|
||||
companion object {
|
||||
@JvmStatic val groupIDKey = "groupIDKey"
|
||||
private val loaderID = 0
|
||||
val addUsersRequestCode = 124
|
||||
val legacyGroupSizeLimit = 10
|
||||
const val groupIDKey = "EditClosedGroupActivity_groupID"
|
||||
}
|
||||
|
||||
// region Lifecycle
|
||||
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
|
||||
super.onCreate(savedInstanceState, isReady)
|
||||
setContentView(R.layout.activity_edit_closed_group)
|
||||
@Inject lateinit var editFactory: EditGroupViewModel.Factory
|
||||
@Inject lateinit var inviteFactory: EditGroupInviteViewModel.Factory
|
||||
|
||||
supportActionBar!!.setHomeAsUpIndicator(
|
||||
ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable))
|
||||
|
||||
groupID = intent.getStringExtra(groupIDKey)!!
|
||||
val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get()
|
||||
originalName = groupInfo.title
|
||||
isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) }
|
||||
|
||||
name = originalName
|
||||
|
||||
mainContentContainer = findViewById(R.id.mainContentContainer)
|
||||
cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit)
|
||||
cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay)
|
||||
edtGroupName = findViewById(R.id.edtGroupName)
|
||||
emptyStateContainer = findViewById(R.id.emptyStateContainer)
|
||||
lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay)
|
||||
loaderContainer = findViewById(R.id.loaderContainer)
|
||||
|
||||
findViewById<View>(R.id.addMembersClosedGroupButton).setOnClickListener {
|
||||
onAddMembersClick()
|
||||
}
|
||||
|
||||
findViewById<RecyclerView>(R.id.rvUserList).apply {
|
||||
adapter = memberListAdapter
|
||||
layoutManager = LinearLayoutManager(this@EditClosedGroupActivity)
|
||||
}
|
||||
|
||||
lblGroupNameDisplay.text = originalName
|
||||
cntGroupNameDisplay.setOnClickListener { isEditingName = true }
|
||||
findViewById<View>(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false }
|
||||
findViewById<View>(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() }
|
||||
edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE)
|
||||
edtGroupName.setOnEditorActionListener { _, actionId, _ ->
|
||||
when (actionId) {
|
||||
EditorInfo.IME_ACTION_DONE -> {
|
||||
saveName()
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
else -> return@setOnEditorActionListener false
|
||||
}
|
||||
}
|
||||
|
||||
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<GroupMembers> {
|
||||
|
||||
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
|
||||
return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<GroupMembers>, groupMembers: GroupMembers) {
|
||||
// We no longer need any subsequent loading events
|
||||
// (they will occur on every activity resume).
|
||||
LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID)
|
||||
|
||||
members.clear()
|
||||
members.addAll(groupMembers.members.toHashSet())
|
||||
zombies.clear()
|
||||
zombies.addAll(groupMembers.zombieMembers.toHashSet())
|
||||
originalMembers.clear()
|
||||
originalMembers.addAll(members + zombies)
|
||||
updateMembers()
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<GroupMembers>) {
|
||||
updateMembers()
|
||||
}
|
||||
})
|
||||
private fun onFinish() {
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_edit_closed_group, menu)
|
||||
return allMembers.isNotEmpty() && !isLoading
|
||||
}
|
||||
// endregion
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
setContent {
|
||||
|
||||
// region Updating
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (requestCode) {
|
||||
addUsersRequestCode -> {
|
||||
if (resultCode != RESULT_OK) return
|
||||
if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return
|
||||
|
||||
val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet()
|
||||
members.addAll(selectedContacts)
|
||||
updateMembers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsEditingNameChanged() {
|
||||
cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE
|
||||
cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE
|
||||
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
if (isEditingName) {
|
||||
edtGroupName.setText(name)
|
||||
edtGroupName.selectAll()
|
||||
edtGroupName.requestFocus()
|
||||
inputMethodManager.showSoftInput(edtGroupName, 0)
|
||||
} else {
|
||||
inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMembers() {
|
||||
memberListAdapter.setMembers(allMembers)
|
||||
memberListAdapter.setZombieMembers(zombies)
|
||||
|
||||
mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE
|
||||
emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_apply -> if (!isLoading) { commitChanges() }
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun onMemberClick(member: String) {
|
||||
val bottomSheet = ClosedGroupEditingOptionsBottomSheet()
|
||||
bottomSheet.onRemoveTapped = {
|
||||
if (zombies.contains(member)) zombies.remove(member)
|
||||
else members.remove(member)
|
||||
updateMembers()
|
||||
bottomSheet.dismiss()
|
||||
}
|
||||
bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet")
|
||||
}
|
||||
|
||||
private fun onAddMembersClick() {
|
||||
val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java)
|
||||
intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
|
||||
intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
|
||||
startActivityForResult(intent, addUsersRequestCode)
|
||||
}
|
||||
|
||||
private fun saveName() {
|
||||
val name = edtGroupName.text.toString().trim()
|
||||
if (name.isEmpty()) {
|
||||
return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
if (name.length >= 64) {
|
||||
return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
this.name = name
|
||||
lblGroupNameDisplay.text = name
|
||||
hasNameChanged = true
|
||||
isEditingName = false
|
||||
}
|
||||
|
||||
private fun commitChanges() {
|
||||
val hasMemberListChanges = (allMembers != originalMembers)
|
||||
|
||||
if (!hasNameChanged && !hasMemberListChanges) {
|
||||
return finish()
|
||||
}
|
||||
|
||||
val name = if (hasNameChanged) this.name else originalName
|
||||
|
||||
val members = this.allMembers.map {
|
||||
Recipient.from(this, Address.fromSerialized(it), false)
|
||||
}.toSet()
|
||||
val originalMembers = this.originalMembers.map {
|
||||
Recipient.from(this, Address.fromSerialized(it), false)
|
||||
}.toSet()
|
||||
|
||||
var isClosedGroup: Boolean
|
||||
var groupPublicKey: String?
|
||||
try {
|
||||
groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString()
|
||||
isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey)
|
||||
} catch (e: IOException) {
|
||||
groupPublicKey = null
|
||||
isClosedGroup = false
|
||||
}
|
||||
|
||||
if (members.isEmpty()) {
|
||||
return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit
|
||||
if (members.size >= maxGroupMembers) {
|
||||
return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(this)!!
|
||||
val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false)
|
||||
|
||||
if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) {
|
||||
val message = "Can't leave while adding or removing other members."
|
||||
return Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
if (isClosedGroup) {
|
||||
isLoading = true
|
||||
loaderContainer.fadeIn()
|
||||
val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
|
||||
MessageSender.explicitLeave(groupPublicKey!!, false)
|
||||
} else {
|
||||
task {
|
||||
if (hasNameChanged) {
|
||||
MessageSender.explicitNameChange(groupPublicKey!!, name)
|
||||
AppTheme {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.editGroup,
|
||||
dependenciesContainerBuilder = {
|
||||
dependency(NavGraphs.editGroup) {
|
||||
editFactory.create(intent.getStringExtra(groupIDKey)!!, contentResolver)
|
||||
}
|
||||
dependency(NavGraphs.editGroup) {
|
||||
inviteFactory.create(intent.getStringExtra(groupIDKey)!!)
|
||||
}
|
||||
dependency(EditClosedGroupScreenDestination) {
|
||||
::onFinish
|
||||
}
|
||||
}
|
||||
members.filterNot { it in originalMembers }.let { adds ->
|
||||
if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() })
|
||||
}
|
||||
originalMembers.filterNot { it in members }.let { removes ->
|
||||
if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.successUi {
|
||||
loaderContainer.fadeOut()
|
||||
isLoading = false
|
||||
updateGroupConfig()
|
||||
finish()
|
||||
}.failUi { exception ->
|
||||
val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
|
||||
Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show()
|
||||
loaderContainer.fadeOut()
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateGroupConfig() {
|
||||
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
|
||||
?: return Log.w("Loki", "No recipient settings when trying to update group config")
|
||||
val latestGroup = storage.getGroup(groupID)
|
||||
?: return Log.w("Loki", "No group record when trying to update group config")
|
||||
groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
|
||||
}
|
||||
|
||||
class GroupMembers(val members: List<String>, val zombieMembers: List<String>)
|
||||
}
|
|
@ -4,13 +4,13 @@ import android.content.Context
|
|||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.AsyncLoader
|
||||
|
||||
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditClosedGroupActivity.GroupMembers>(context) {
|
||||
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditLegacyClosedGroupActivity.GroupMembers>(context) {
|
||||
|
||||
override fun loadInBackground(): EditClosedGroupActivity.GroupMembers {
|
||||
override fun loadInBackground(): EditLegacyClosedGroupActivity.GroupMembers {
|
||||
val groupDatabase = DatabaseComponent.get(context).groupDatabase()
|
||||
val members = groupDatabase.getGroupMembers(groupID, true)
|
||||
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
|
||||
return EditClosedGroupActivity.GroupMembers(
|
||||
return EditLegacyClosedGroupActivity.GroupMembers(
|
||||
members.map {
|
||||
it.address.toString()
|
||||
},
|
||||
|
|
|
@ -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>)
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
package org.thoughtcrime.securesms.groups
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.thoughtcrime.securesms.groups.compose
|
||||
|
||||
import com.ramcosta.composedestinations.annotation.NavGraph
|
||||
|
||||
@NavGraph
|
||||
annotation class CreateGroupNavGraph(
|
||||
val start: Boolean = false
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.thoughtcrime.securesms.groups.compose
|
||||
|
||||
import com.ramcosta.composedestinations.annotation.NavGraph
|
||||
|
||||
@NavGraph
|
||||
annotation class EditGroupNavGraph(
|
||||
val start: Boolean = false
|
||||
)
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
|
||||
push(intent)
|
||||
}
|
||||
is GlobalSearchAdapter.Model.GroupConversation -> {
|
||||
is GlobalSearchAdapter.Model.LegacyGroupConversation -> {
|
||||
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
|
||||
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
|
||||
if (threadId >= 0) {
|
||||
|
@ -258,7 +258,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||
globalSearchViewModel.result.collect { result ->
|
||||
val currentUserPublicKey = publicKey
|
||||
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } +
|
||||
result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) }
|
||||
result.threads.map { GlobalSearchAdapter.Model.LegacyGroupConversation(it) }
|
||||
|
||||
val contactResults = contactAndGroupList.toMutableList()
|
||||
|
||||
|
@ -334,9 +334,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||
}
|
||||
|
||||
private fun setupMessageRequestsBanner() {
|
||||
val messageRequestCount = threadDb.unapprovedConversationCount
|
||||
val messageRequestCount = threadDb.unapprovedConversationList.use { it.count }
|
||||
// Set up message requests
|
||||
if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) {
|
||||
if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests() && messageRequestCount != homeAdapter.requestCount) {
|
||||
with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) {
|
||||
unreadCountTextView.text = messageRequestCount.toString()
|
||||
timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(
|
||||
|
@ -352,13 +352,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||
if (hadHeader) homeAdapter.notifyItemChanged(0)
|
||||
else homeAdapter.notifyItemInserted(0)
|
||||
}
|
||||
} else {
|
||||
} else if (messageRequestCount == 0) {
|
||||
val hadHeader = homeAdapter.hasHeaderView()
|
||||
homeAdapter.header = null
|
||||
if (hadHeader) {
|
||||
homeAdapter.notifyItemRemoved(0)
|
||||
}
|
||||
}
|
||||
homeAdapter.requestCount = messageRequestCount
|
||||
}
|
||||
|
||||
private fun updateLegacyConfigView() {
|
||||
|
@ -644,14 +645,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||
// Cancel any outstanding jobs
|
||||
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
|
||||
// Send a leave group message if this is an active closed group
|
||||
if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
|
||||
if (recipient.address.isLegacyClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
|
||||
try {
|
||||
GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
|
||||
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
|
||||
?.let { MessageSender.explicitLeave(it, false) }
|
||||
} catch (_: IOException) {
|
||||
} catch (e: IOException) {
|
||||
Log.e("Loki", e)
|
||||
}
|
||||
}
|
||||
if (recipient.address.isClosedGroup) {
|
||||
TODO("Implement leaving / deleting a new closed group conversation")
|
||||
}
|
||||
// Delete the conversation
|
||||
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
|
||||
if (v2OpenGroup != null) {
|
||||
|
|
|
@ -38,6 +38,7 @@ class HomeAdapter(
|
|||
}
|
||||
|
||||
fun hasHeaderView(): Boolean = header != null
|
||||
var requestCount = 0
|
||||
|
||||
private val headerCount: Int
|
||||
get() = if (header == null) 0 else 1
|
||||
|
|
|
@ -9,9 +9,10 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
|
||||
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
|
||||
import network.loki.messenger.libsession_util.util.GroupInfo
|
||||
import org.session.libsession.utilities.GroupRecord
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult
|
||||
import java.security.InvalidParameterException
|
||||
import org.session.libsession.messaging.contacts.Contact as ContactModel
|
||||
|
@ -98,7 +99,7 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
|
|||
fun bind(query: String, model: Model) {
|
||||
binding.searchResultProfilePicture.recycle()
|
||||
when (model) {
|
||||
is Model.GroupConversation -> bindModel(query, model)
|
||||
is Model.LegacyGroupConversation -> bindModel(query, model)
|
||||
is Model.Contact -> bindModel(query, model)
|
||||
is Model.Message -> bindModel(query, model)
|
||||
is Model.SavedMessages -> bindModel(model)
|
||||
|
@ -119,7 +120,8 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
|
|||
data class Header(@StringRes val title: Int) : Model()
|
||||
data class SavedMessages(val currentUserPublicKey: String): Model()
|
||||
data class Contact(val contact: ContactModel) : Model()
|
||||
data class GroupConversation(val groupRecord: GroupRecord) : Model()
|
||||
data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model()
|
||||
data class ClosedGroupConversation(val sessionId: SessionId)
|
||||
data class Message(val messageResult: MessageResult, val unread: Int) : Model()
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import org.session.libsession.messaging.contacts.Contact
|
|||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
|
||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
|
||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation
|
||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
|
||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
|
||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
|
||||
|
@ -65,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
|
|||
binding.searchResultSubtitle.isVisible = true
|
||||
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
|
||||
}
|
||||
is GroupConversation -> {
|
||||
is LegacyGroupConversation -> {
|
||||
binding.searchResultTitle.text = getHighlight(
|
||||
query,
|
||||
model.groupRecord.title
|
||||
|
@ -86,10 +86,10 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
|
|||
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query)
|
||||
}
|
||||
|
||||
fun ContentView.bindModel(query: String?, model: GroupConversation) {
|
||||
fun ContentView.bindModel(query: String?, model: LegacyGroupConversation) {
|
||||
binding.searchResultProfilePicture.isVisible = true
|
||||
binding.searchResultSavedMessages.isVisible = false
|
||||
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
|
||||
binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup
|
||||
binding.searchResultTimestamp.isVisible = false
|
||||
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
|
||||
binding.searchResultProfilePicture.update(threadRecipient)
|
||||
|
@ -102,7 +102,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
|
|||
val address = it.address.serialize()
|
||||
it.name ?: "${address.take(4)}...${address.takeLast(4)}"
|
||||
}
|
||||
if (model.groupRecord.isClosedGroup) {
|
||||
if (model.groupRecord.isLegacyClosedGroup) {
|
||||
binding.searchResultSubtitle.text = getHighlight(query, membersString)
|
||||
}
|
||||
}
|
||||
|
@ -132,11 +132,6 @@ fun ContentView.bindModel(query: String?, model: Message) {
|
|||
binding.searchResultProfilePicture.isVisible = true
|
||||
binding.searchResultSavedMessages.isVisible = false
|
||||
binding.searchResultTimestamp.isVisible = true
|
||||
// val hasUnreads = model.unread > 0
|
||||
// binding.unreadCountIndicator.isVisible = hasUnreads
|
||||
// if (hasUnreads) {
|
||||
// binding.unreadCountTextView.text = model.unread.toString()
|
||||
// }
|
||||
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
|
||||
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
|
||||
val textSpannable = SpannableStringBuilder()
|
||||
|
|
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messagerequests
|
|||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.text.SpannableString
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.ContextThemeWrapper
|
||||
|
@ -61,10 +62,14 @@ class MessageRequestsAdapter(
|
|||
val item = popupMenu.menu.getItem(i)
|
||||
val s = SpannableString(item.title)
|
||||
s.setSpan(ForegroundColorSpan(context.getColor(R.color.destructive)), 0, s.length, 0)
|
||||
item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive))
|
||||
}
|
||||
item.title = s
|
||||
}
|
||||
popupMenu.setForceShowIcon(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
popupMenu.setForceShowIcon(true)
|
||||
}
|
||||
popupMenu.show()
|
||||
}
|
||||
|
||||
|
|
|
@ -7,8 +7,8 @@ import androidx.work.Constraints
|
|||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
|
@ -18,7 +18,7 @@ import nl.komponents.kovenant.functional.bind
|
|||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
|
||||
import org.session.libsession.messaging.jobs.MessageReceiveParameters
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
|
@ -121,7 +121,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
|
|||
|
||||
// Closed groups
|
||||
if (requestTargets.contains(Targets.CLOSED_GROUPS)) {
|
||||
val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared
|
||||
val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
|
||||
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }
|
||||
|
|
|
@ -42,7 +42,6 @@ import com.goterl.lazysodium.utils.KeyPair;
|
|||
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup;
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
|
||||
import org.session.libsession.messaging.utilities.SessionId;
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
|
@ -52,6 +51,7 @@ import org.session.libsession.utilities.TextSecurePreferences;
|
|||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsignal.utilities.IdPrefix;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.SessionId;
|
||||
import org.session.libsignal.utilities.Util;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
||||
|
@ -555,7 +555,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
|
|||
if (openGroup != null && edKeyPair != null) {
|
||||
KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair);
|
||||
if (blindedKeyPair != null) {
|
||||
return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
|
||||
return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).hexString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -73,7 +73,7 @@ class PushRegistry @Inject constructor(
|
|||
token: String,
|
||||
publicKey: String,
|
||||
userEd25519Key: KeyPair,
|
||||
namespaces: List<Int> = listOf(Namespace.DEFAULT)
|
||||
namespaces: List<Int> = listOf(Namespace.DEFAULT())
|
||||
): Promise<*, Exception> {
|
||||
Log.d(TAG, "register() called")
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver)
|
|||
val requestParameters = SubscriptionRequest(
|
||||
pubkey = publicKey,
|
||||
session_ed25519 = userEd25519Key.publicKey.asHexString,
|
||||
namespaces = listOf(Namespace.DEFAULT),
|
||||
namespaces = listOf(Namespace.DEFAULT()),
|
||||
data = true, // only permit data subscription for now (?)
|
||||
service = device.service,
|
||||
sig_ts = timestamp,
|
||||
|
|
|
@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
|
@ -28,7 +29,7 @@ import org.thoughtcrime.securesms.util.adapter.SelectableItem
|
|||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BlockedContactsViewModel @Inject constructor(private val storage: Storage): ViewModel() {
|
||||
class BlockedContactsViewModel @Inject constructor(private val storage: StorageProtocol): ViewModel() {
|
||||
|
||||
private val executor = viewModelScope + SupervisorJob()
|
||||
|
||||
|
|
|
@ -104,7 +104,8 @@ class ClearAllDataDialog : DialogFragment() {
|
|||
|
||||
if (!deleteNetworkMessages) {
|
||||
try {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get()
|
||||
// TODO: maybe convert this to a blocking config job
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext())
|
||||
} catch (e: Exception) {
|
||||
Log.e("Loki", "Failed to force sync", e)
|
||||
}
|
||||
|
|
|
@ -9,11 +9,10 @@ import android.widget.TextView;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.session.libsession.messaging.utilities.SessionId;
|
||||
import org.session.libsignal.utilities.SessionId;
|
||||
import org.thoughtcrime.securesms.components.ProfilePictureView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
|
|
@ -77,7 +77,7 @@ interface ConversationRepository {
|
|||
|
||||
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit>
|
||||
|
||||
fun declineMessageRequest(threadId: Long)
|
||||
fun declineMessageRequest(threadId: Long, recipient: Recipient)
|
||||
|
||||
fun hasReceived(threadId: Long): Boolean
|
||||
|
||||
|
@ -286,8 +286,7 @@ class DefaultConversationRepository @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> {
|
||||
sessionJobDb.cancelPendingMessageSendJobs(thread.threadId)
|
||||
storage.deleteConversation(thread.threadId)
|
||||
declineMessageRequest(thread.threadId, thread.recipient)
|
||||
return ResultOf.Success(Unit)
|
||||
}
|
||||
|
||||
|
@ -306,19 +305,27 @@ class DefaultConversationRepository @Inject constructor(
|
|||
|
||||
override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation ->
|
||||
storage.setRecipientApproved(recipient, true)
|
||||
val message = MessageRequestResponse(true)
|
||||
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
|
||||
.success {
|
||||
threadDb.setHasSent(threadId, true)
|
||||
continuation.resume(ResultOf.Success(Unit))
|
||||
}.fail { error ->
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
if (recipient.isClosedGroupRecipient) {
|
||||
storage.respondToClosedGroupInvitation(recipient, true)
|
||||
} else {
|
||||
val message = MessageRequestResponse(true)
|
||||
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
|
||||
.success {
|
||||
threadDb.setHasSent(threadId, true)
|
||||
continuation.resume(ResultOf.Success(Unit))
|
||||
}.fail { error ->
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun declineMessageRequest(threadId: Long) {
|
||||
override fun declineMessageRequest(threadId: Long, recipient: Recipient) {
|
||||
sessionJobDb.cancelPendingMessageSendJobs(threadId)
|
||||
storage.deleteConversation(threadId)
|
||||
if (recipient.isClosedGroupRecipient) {
|
||||
storage.respondToClosedGroupInvitation(recipient, false)
|
||||
} else {
|
||||
storage.deleteConversation(threadId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasReceived(threadId: Long): Boolean {
|
||||
|
|
|
@ -5,11 +5,11 @@ import network.loki.messenger.libsession_util.util.UserPic
|
|||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.utilities.SSKEnvironment
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
|
|
|
@ -2,18 +2,28 @@ package org.thoughtcrime.securesms.ui
|
|||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.ButtonColors
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Colors
|
||||
|
@ -22,15 +32,24 @@ import androidx.compose.material.IconButton
|
|||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -95,7 +114,7 @@ fun CellWithPaddingAndMargin(
|
|||
}
|
||||
}
|
||||
|
||||
private val Colors.cellColor: Color
|
||||
val Colors.cellColor: Color
|
||||
@Composable
|
||||
get() = LocalExtraColors.current.settingsBackground
|
||||
|
||||
|
@ -180,3 +199,160 @@ fun RowScope.Avatar(recipient: Recipient) {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableAvatar(
|
||||
// TODO: add attachment-based state for current view rendering?
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier
|
||||
.size(110.dp)
|
||||
.padding(15.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.avatar_placeholder),
|
||||
contentDescription = stringResource(
|
||||
id = R.string.arrays__default
|
||||
),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.cellColor, shape = CircleShape)
|
||||
.padding(16.dp)
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_plus),
|
||||
contentDescription = null,
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(24.dp)
|
||||
.background(colorDestructive, shape = CircleShape)
|
||||
.padding(6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
query: String,
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.background(MaterialTheme.colors.primaryVariant, RoundedCornerShape(100))
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = R.drawable.ic_search_24),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
MaterialTheme.colors.onPrimary
|
||||
),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
singleLine = true,
|
||||
// label = { Text(text = stringResource(id = R.string.search_contacts_hint),modifier=Modifier.padding(0.dp)) },
|
||||
value = query,
|
||||
onValueChange = onValueChanged,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.padding(4.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBar(
|
||||
title: String,
|
||||
titleAlignment: Alignment = Alignment.Center,
|
||||
onBack: (() -> Unit)? = null,
|
||||
actionElement: (@Composable BoxScope.() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)) {
|
||||
// Optional back button, layout should still take up space
|
||||
Box(modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1.0f, true)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
contentDescription = stringResource(
|
||||
id = R.string.new_conversation_dialog_back_button_content_description
|
||||
),
|
||||
Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false, radius = 16.dp),
|
||||
) { onBack() }
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
//Main title
|
||||
Box(modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
.padding(8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
Modifier.align(titleAlignment),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
// Optional action
|
||||
if (actionElement != null) {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.CenterVertically)
|
||||
.aspectRatio(1.0f, true),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
actionElement(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.CloseIcon(onClose: ()->Unit) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_baseline_close_24),
|
||||
contentDescription = stringResource(
|
||||
id = R.string.new_conversation_dialog_close_button_content_description
|
||||
),
|
||||
Modifier
|
||||
.clickable { onClose() }
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewNavigationBar(@PreviewParameter(provider = ThemeResPreviewParameterProvider::class) themeResId: Int) {
|
||||
PreviewTheme(themeResId = themeResId) {
|
||||
NavigationBar(title = "Create Group", onBack = {}, actionElement = {
|
||||
CloseIcon {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewSearchBar(@PreviewParameter(provider = ThemeResPreviewParameterProvider::class) themeResId: Int) {
|
||||
PreviewTheme(themeResId = themeResId) {
|
||||
SearchBar("", {})
|
||||
}
|
||||
}
|
|
@ -18,10 +18,12 @@ import com.google.android.material.color.MaterialColors
|
|||
import network.loki.messenger.R
|
||||
|
||||
val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { error("No Custom Attribute value provided") }
|
||||
val LocalPreviewMode = staticCompositionLocalOf { false }
|
||||
|
||||
|
||||
data class ExtraColors(
|
||||
val settingsBackground: Color,
|
||||
val destructive: Color
|
||||
)
|
||||
|
||||
/**
|
||||
|
@ -34,6 +36,7 @@ fun AppTheme(
|
|||
val extraColors = LocalContext.current.run {
|
||||
ExtraColors(
|
||||
settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground),
|
||||
destructive = Color(getColor(R.color.destructive)),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -56,7 +59,8 @@ fun PreviewTheme(
|
|||
content: @Composable () -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId)
|
||||
LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId),
|
||||
LocalPreviewMode provides true
|
||||
) {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -11,7 +11,6 @@ import network.loki.messenger.libsession_util.util.Contact
|
|||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import network.loki.messenger.libsession_util.util.GroupInfo
|
||||
import network.loki.messenger.libsession_util.util.UserPic
|
||||
import nl.komponents.kovenant.Promise
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.jobs.ConfigurationSyncJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
|
@ -26,28 +25,48 @@ import org.session.libsession.utilities.WindowDebouncer
|
|||
import org.session.libsignal.crypto.ecc.DjbECPublicKey
|
||||
import org.session.libsignal.utilities.Hex
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.SessionId
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import java.util.Timer
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
|
||||
object ConfigurationMessageUtilities {
|
||||
|
||||
private val debouncer = WindowDebouncer(3000, Timer())
|
||||
private val destinationUpdater = Any()
|
||||
private val pendingDestinations = ConcurrentLinkedDeque<Destination>()
|
||||
|
||||
private fun scheduleConfigSync(userPublicKey: String) {
|
||||
private fun scheduleConfigSync(destination: Destination) {
|
||||
synchronized(destinationUpdater) {
|
||||
pendingDestinations.add(destination)
|
||||
}
|
||||
debouncer.publish {
|
||||
// don't schedule job if we already have one
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val ourDestination = Destination.Contact(userPublicKey)
|
||||
val currentStorageJob = storage.getConfigSyncJob(ourDestination)
|
||||
if (currentStorageJob != null) {
|
||||
(currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
|
||||
return@publish
|
||||
val configFactory = MessagingModuleConfiguration.shared.configFactory
|
||||
val destinations = synchronized(destinationUpdater) {
|
||||
val objects = pendingDestinations.toList()
|
||||
pendingDestinations.clear()
|
||||
objects
|
||||
}
|
||||
destinations.forEach { destination ->
|
||||
if (destination is Destination.ClosedGroup) {
|
||||
// ensure we have the appropriate admin keys, skip this destination otherwise
|
||||
val group = configFactory.userGroups?.getClosedGroup(destination.publicKey) ?: return@forEach
|
||||
if (group.adminKey.isEmpty()) return@forEach Log.w("ConfigurationSync", "Trying to schedule config sync for group we aren't an admin of")
|
||||
}
|
||||
val currentStorageJob = storage.getConfigSyncJob(destination)
|
||||
if (currentStorageJob != null) {
|
||||
(currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
|
||||
return@publish
|
||||
}
|
||||
val newConfigSync = ConfigurationSyncJob(destination)
|
||||
JobQueue.shared.add(newConfigSync)
|
||||
}
|
||||
val newConfigSync = ConfigurationSyncJob(ourDestination)
|
||||
JobQueue.shared.add(newConfigSync)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,7 +77,7 @@ object ConfigurationMessageUtilities {
|
|||
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
|
||||
val currentTime = SnodeAPI.nowWithOffset
|
||||
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
|
||||
scheduleConfigSync(userPublicKey)
|
||||
scheduleConfigSync(Destination.Contact(userPublicKey))
|
||||
return
|
||||
}
|
||||
val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context)
|
||||
|
@ -82,34 +101,21 @@ object ConfigurationMessageUtilities {
|
|||
TextSecurePreferences.setLastConfigurationSyncTime(context, now)
|
||||
}
|
||||
|
||||
fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> {
|
||||
fun forceSyncConfigurationNowIfNeeded(destination: Destination) {
|
||||
scheduleConfigSync(destination)
|
||||
}
|
||||
|
||||
|
||||
fun forceSyncConfigurationNowIfNeeded(context: Context) {
|
||||
// add if check here to schedule new config job process and return early
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null"))
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.e("Loki", NullPointerException("User Public Key is null"))
|
||||
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
|
||||
val currentTime = SnodeAPI.nowWithOffset
|
||||
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
|
||||
// schedule job if none exist
|
||||
// don't schedule job if we already have one
|
||||
scheduleConfigSync(userPublicKey)
|
||||
return Promise.ofSuccess(Unit)
|
||||
scheduleConfigSync(Destination.Contact(userPublicKey))
|
||||
}
|
||||
val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
|
||||
!recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
|
||||
}.map { recipient ->
|
||||
ConfigurationMessage.Contact(
|
||||
publicKey = recipient.address.serialize(),
|
||||
name = recipient.name!!,
|
||||
profilePicture = recipient.profileAvatar,
|
||||
profileKey = recipient.profileKey,
|
||||
isApproved = recipient.isApproved,
|
||||
isBlocked = recipient.isBlocked,
|
||||
didApproveMe = recipient.hasApprovedMe()
|
||||
)
|
||||
}
|
||||
val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit)
|
||||
val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true)
|
||||
TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis())
|
||||
return promise
|
||||
}
|
||||
|
||||
private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes
|
||||
|
@ -199,6 +205,11 @@ object ConfigurationMessageUtilities {
|
|||
convoConfig.getOrConstructCommunity(base, room, pubKey)
|
||||
}
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
// It's probably safe to assume there will never be a case where new closed groups will ever be there before a dump is created...
|
||||
// but just in case...
|
||||
convoConfig.getOrConstructClosedGroup(recipient.address.serialize())
|
||||
}
|
||||
recipient.isLegacyClosedGroupRecipient -> {
|
||||
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
|
||||
convoConfig.getOrConstructLegacyGroup(groupPublicKey)
|
||||
}
|
||||
|
@ -241,7 +252,7 @@ object ConfigurationMessageUtilities {
|
|||
}
|
||||
|
||||
val allLgc = storage.getAllGroups(includeInactive = false).filter {
|
||||
it.isClosedGroup && it.isActive && it.members.size > 1
|
||||
it.isLegacyClosedGroup && it.isActive && it.members.size > 1
|
||||
}.mapNotNull { group ->
|
||||
val groupAddress = Address.fromSerialized(group.encodedId)
|
||||
val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString()
|
||||
|
@ -252,7 +263,7 @@ object ConfigurationMessageUtilities {
|
|||
val admins = group.admins.map { it.serialize() to true }.toMap()
|
||||
val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap()
|
||||
GroupInfo.LegacyGroupInfo(
|
||||
sessionId = groupPublicKey,
|
||||
sessionId = SessionId.from(groupPublicKey),
|
||||
name = group.title,
|
||||
members = admins + members,
|
||||
priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
|
||||
|
@ -273,13 +284,13 @@ object ConfigurationMessageUtilities {
|
|||
|
||||
@JvmField
|
||||
val DELETE_INACTIVE_GROUPS: String = """
|
||||
DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%');
|
||||
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%');
|
||||
DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%');
|
||||
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%');
|
||||
""".trimIndent()
|
||||
|
||||
@JvmField
|
||||
val DELETE_INACTIVE_ONE_TO_ONES: String = """
|
||||
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%';
|
||||
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%';
|
||||
""".trimIndent()
|
||||
|
||||
}
|
|
@ -13,6 +13,8 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool
|
|||
&& recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) {
|
||||
return getOneToOne(recipient.address.serialize())?.unread == true
|
||||
} else if (recipient.isClosedGroupRecipient) {
|
||||
return getClosedGroup(recipient.address.serialize())?.unread == true
|
||||
} else if (recipient.isLegacyClosedGroupRecipient) {
|
||||
return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true
|
||||
} else if (recipient.isOpenGroupRecipient) {
|
||||
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -4,7 +4,7 @@
|
|||
android:layout_height="match_parent"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:context="org.thoughtcrime.securesms.groups.EditClosedGroupActivity">
|
||||
tools:context="org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/mainContentContainer"
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue