diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 000000000..e7f705722 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,10 @@ +--- +kind: pipeline +type: docker +name: default + +steps: +- name: test + image: mingc/android-build-box:1.24.0 + commands: + - bash ./gradlew test \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index bfb73f9fc..ed3250f0c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,12 +17,13 @@ buildscript { plugins { id 'kotlin-kapt' id 'com.google.dagger.hilt.android' + id 'com.google.devtools.ksp' + id 'app.cash.molecule' } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'witness' -apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlinx-serialization' apply plugin: 'dagger.hilt.android.plugin' @@ -47,12 +48,12 @@ android { useLibrary 'org.apache.http.legacy' compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '11' } packagingOptions { @@ -78,7 +79,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.4.7' + kotlinCompilerExtensionVersion '1.5.3' } defaultConfig { @@ -205,8 +206,14 @@ android { dependencies { - implementation("com.google.dagger:hilt-android:2.46.1") - kapt("com.google.dagger:hilt-android-compiler:2.44") + implementation("com.google.dagger:hilt-android:$daggerVersion") + kapt("com.google.dagger:hilt-android-compiler:$daggerVersion") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + + implementation "io.github.raamcosta.compose-destinations:core:$composeDestinationsVersion" + ksp "io.github.raamcosta.compose-destinations:ksp:$composeDestinationsVersion" + + implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation 'androidx.recyclerview:recyclerview:1.2.1' @@ -299,9 +306,9 @@ dependencies { implementation "com.opencsv:opencsv:4.6" testImplementation "junit:junit:$junitVersion" testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.10.0" + testImplementation "org.mockito:mockito-inline:4.11.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - androidTestImplementation "org.mockito:mockito-android:4.10.0" + androidTestImplementation "org.mockito:mockito-android:4.11.0" androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.2.0" @@ -313,7 +320,6 @@ dependencies { androidTestImplementation('com.adevinta.android:barista:4.2.0') { exclude group: 'org.jetbrains.kotlin' } - // AndroidJUnitRunner and JUnit Rules androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:rules:1.5.0' @@ -331,20 +337,22 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3" + debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3" androidTestUtil 'androidx.test:orchestrator:1.4.2' testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' - implementation 'androidx.compose.ui:ui:1.5.2' - implementation 'androidx.compose.ui:ui-tooling:1.5.2' + implementation 'androidx.compose.ui:ui:1.5.3' + implementation 'androidx.compose.ui:ui-tooling:1.5.3' implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha" - implementation "androidx.compose.runtime:runtime-livedata:1.5.2" + implementation "androidx.compose.runtime:runtime-livedata:1.5.3" - implementation 'androidx.compose.foundation:foundation-layout:1.5.2' - implementation 'androidx.compose.material:material:1.5.2' + implementation 'androidx.compose.foundation:foundation-layout:1.5.3' + implementation 'androidx.compose.material:material:1.5.3' } static def getLastCommitTimestamp() { diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index deab87dd6..be464ad75 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -1,8 +1,10 @@ + xmlns:android="http://schemas.android.com/apk/res/android"> + + + \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt b/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt new file mode 100644 index 000000000..a03a225a1 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt @@ -0,0 +1,143 @@ +package network.loki.messenger + +import androidx.compose.ui.test.hasContentDescriptionExactly +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.CoreMatchers.* +import org.hamcrest.MatcherAssert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.groups.compose.CreateGroup +import org.thoughtcrime.securesms.groups.compose.ViewState +import org.thoughtcrime.securesms.ui.AppTheme + +@RunWith(AndroidJUnit4::class) +@SmallTest +class CreateGroupTests { + + @get:Rule + val composeTest = createComposeRule() + + @Test + fun testNavigateToCreateGroup() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + // Accessibility IDs + val nameDesc = application.getString(R.string.AccessibilityId_closed_group_edit_group_name) + val buttonDesc = application.getString(R.string.AccessibilityId_create_closed_group_create_button) + + var backPressed = false + var closePressed = false + + composeTest.setContent { + AppTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { backPressed = true }, + onClose = { closePressed = true }, + onSelectContact = {}, + updateState = {} + ) + } + } + + with(composeTest) { + onNode(hasContentDescriptionExactly(nameDesc)).performTextInput("Name") + onNode(hasContentDescriptionExactly(buttonDesc)).performClick() + } + + assertThat(backPressed, equalTo(false)) + assertThat(closePressed, equalTo(false)) + + } + + @Test + fun testFailToCreate() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + // Accessibility IDs + val nameDesc = application.getString(R.string.AccessibilityId_closed_group_edit_group_name) + val buttonDesc = application.getString(R.string.AccessibilityId_create_closed_group_create_button) + + var backPressed = false + var closePressed = false + + composeTest.setContent { + AppTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { backPressed = true }, + onClose = { closePressed = true }, + updateState = {}, + onSelectContact = {} + ) + } + } + with(composeTest) { + onNode(hasContentDescriptionExactly(nameDesc)).performTextInput("") + onNode(hasContentDescriptionExactly(buttonDesc)).performClick() + } + + assertThat(backPressed, equalTo(false)) + assertThat(closePressed, equalTo(false)) + } + + @Test + fun testBackButton() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + // Accessibility IDs + val backDesc = application.getString(R.string.new_conversation_dialog_back_button_content_description) + + var backPressed = false + + composeTest.setContent { + AppTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { backPressed = true }, + onClose = {}, + onSelectContact = {}, + updateState = {} + ) + } + } + + with (composeTest) { + onNode(hasContentDescriptionExactly(backDesc)).performClick() + } + + assertThat(backPressed, equalTo(true)) + } + + @Test + fun testCloseButton() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + // Accessibility IDs + val closeDesc = application.getString(R.string.new_conversation_dialog_close_button_content_description) + var closePressed = false + + composeTest.setContent { + AppTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { }, + onClose = { closePressed = true }, + onSelectContact = {}, + updateState = {} + ) + } + } + + with (composeTest) { + onNode(hasContentDescriptionExactly(closeDesc)).performClick() + } + + assertThat(closePressed, equalTo(true)) + } + + +} \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index a20a3a2a6..822564b4f 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -1,14 +1,10 @@ package network.loki.messenger -import android.Manifest import android.app.Instrumentation import android.content.ClipboardManager import android.content.Context -import android.view.View import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed @@ -20,11 +16,11 @@ import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest +import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry -import com.adevinta.android.barista.interaction.PermissionGranter -import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable -import org.hamcrest.Matcher +import network.loki.messenger.util.sendMessage +import network.loki.messenger.util.setupLoggedInState +import network.loki.messenger.util.waitFor import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not import org.junit.After @@ -36,12 +32,10 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar import org.thoughtcrime.securesms.home.HomeActivity -import org.thoughtcrime.securesms.mms.GlideApp @RunWith(AndroidJUnit4::class) -@LargeTest +@SmallTest class HomeActivityTests { @get:Rule @@ -59,38 +53,6 @@ class HomeActivityTests { InstrumentationRegistry.getInstrumentation().removeMonitor(activityMonitor) } - private fun sendMessage(messageToSend: String, linkPreview: LinkPreview? = null) { - // assume in chat activity - onView(allOf(isDescendantOfA(withId(R.id.inputBar)),withId(R.id.inputBarEditText))).perform(ViewActions.replaceText(messageToSend)) - if (linkPreview != null) { - val activity = activityMonitor.waitForActivity() as ConversationActivityV2 - val glide = GlideApp.with(activity) - activity.findViewById(R.id.inputBar).updateLinkPreviewDraft(glide, linkPreview) - } - onView(allOf(isDescendantOfA(withId(R.id.inputBar)),inputButtonWithDrawable(R.drawable.ic_arrow_up))).perform(ViewActions.click()) - // TODO: text can flaky on cursor reload, figure out a better way to wait for the UI to settle with new data - onView(isRoot()).perform(waitFor(500)) - } - - private fun setupLoggedInState(hasViewedSeed: Boolean = false) { - // landing activity - onView(withId(R.id.registerButton)).perform(ViewActions.click()) - // session ID - register activity - onView(withId(R.id.registerButton)).perform(ViewActions.click()) - // display name selection - onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123")) - onView(withId(R.id.registerButton)).perform(ViewActions.click()) - // PN select - if (hasViewedSeed) { - // has viewed seed is set to false after register activity - TextSecurePreferences.setHasViewedSeed(InstrumentationRegistry.getInstrumentation().targetContext, true) - } - onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click()) - onView(withId(R.id.registerButton)).perform(ViewActions.click()) - // allow notification permission - PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) - } - private fun goToMyChat() { onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) @@ -134,11 +96,13 @@ class HomeActivityTests { setupLoggedInState() goToMyChat() TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) - sendMessage("howdy") - sendMessage("test") - // tests url rewriter doesn't crash - sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest") - sendMessage("https://www.ámazon.com") + with (activityMonitor.waitForActivity() as ConversationActivityV2) { + sendMessage("howdy") + sendMessage("test") + // tests url rewriter doesn't crash + sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest") + sendMessage("https://www.ámazon.com") + } } @Test @@ -148,7 +112,9 @@ class HomeActivityTests { TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) // given the link url text val url = "https://www.ámazon.com" - sendMessage(url, LinkPreview(url, "amazon", Optional.absent())) + with (activityMonitor.waitForActivity() as ConversationActivityV2) { + sendMessage(url, LinkPreview(url, "amazon", Optional.absent())) + } // when the URL span is clicked onView(withSubstring(url)).perform(ViewActions.click()) @@ -162,21 +128,4 @@ class HomeActivityTests { onView(withText(dialogPromptText)).check(matches(isDisplayed())) } - /** - * Perform action of waiting for a specific time. - */ - fun waitFor(millis: Long): ViewAction { - return object : ViewAction { - override fun getConstraints(): Matcher? { - return isRoot() - } - - override fun getDescription(): String = "Wait for $millis milliseconds." - - override fun perform(uiController: UiController, view: View?) { - uiController.loopMainThreadForAtLeast(millis) - } - } - } - } \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt index 59cb8ede0..e58f1db5f 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -9,12 +9,15 @@ import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.util.applySpiedStorage +import network.loki.messenger.util.maybeGetUserInfo +import network.loki.messenger.util.randomSeedBytes +import network.loki.messenger.util.randomSessionId import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.argThat import org.mockito.kotlin.eq -import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.TextSecurePreferences @@ -22,32 +25,15 @@ import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import kotlin.random.Random @RunWith(AndroidJUnit4::class) @SmallTest class LibSessionTests { - private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) - private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey - private var fakeHashI = 0 private val nextFakeHash: String get() = "fakehash${fakeHashI++}" - private fun maybeGetUserInfo(): Pair? { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext - val prefs = appContext.prefs - val localUserPublicKey = prefs.getLocalNumber() - val secretKey = with(appContext) { - val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null - edKey.secretKey.asBytes - } - return if (localUserPublicKey == null || secretKey == null) null - else secretKey to localUserPublicKey - } - private fun buildContactMessage(contactList: List): ByteArray { val (key,_) = maybeGetUserInfo()!! val contacts = Contacts.Companion.newInstance(key) @@ -80,9 +66,8 @@ class LibSessionTests { @Test fun migration_one_to_ones() { - val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext - val storageSpy = spy(app.storage) - app.storage = storageSpy + val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storage = applicationContext.applySpiedStorage() val newContactId = randomSessionId() val singleContact = Contact( @@ -93,10 +78,10 @@ class LibSessionTests { val newContactMerge = buildContactMessage(listOf(singleContact)) val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! fakePollNewConfig(contacts, newContactMerge) - verify(storageSpy).addLibSessionContacts(argThat { + verify(storage).addLibSessionContacts(argThat { first().let { it.id == newContactId && it.approved } && size == 1 }) - verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) + verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) } } \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/groups/ClosedGroupViewTests.kt b/app/src/androidTest/java/network/loki/messenger/groups/ClosedGroupViewTests.kt new file mode 100644 index 000000000..36f8e5cd2 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/groups/ClosedGroupViewTests.kt @@ -0,0 +1,77 @@ +package network.loki.messenger.groups + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import junit.framework.TestCase.assertNotNull +import network.loki.messenger.libsession_util.util.Sodium +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.SessionId +import org.thoughtcrime.securesms.database.ConfigDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.groups.CreateGroupViewModel + +@RunWith(MockitoJUnitRunner::class) +class ClosedGroupViewTests { + + companion object { + private const val OTHER_ID = "051000000000000000000000000000000000000000000000000000000000000000" + } + + private val seed = + Hex.fromStringCondensed("0123456789abcdef0123456789abcdef00000000000000000000000000000000") + private val keyPair = Sodium.ed25519KeyPair(seed) + private val userSessionId = SessionId(IdPrefix.STANDARD, Sodium.ed25519PkToCurve25519(keyPair.pubKey)) + + @Mock lateinit var textSecurePreferences: TextSecurePreferences + lateinit var storage: Storage + + @Before + fun setup() { + val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext + whenever(textSecurePreferences.getLocalNumber()).thenReturn(userSessionId.hexString()) + val context = mock() + val emptyDb = mock { db -> + whenever(db.retrieveConfigAndHashes(any(), any())).thenReturn(byteArrayOf()) + } + val overriddenStorage = Storage(applicationContext, mock(), ConfigFactory(context, emptyDb) { + keyPair.secretKey to userSessionId.hexString() + }, mock(), { stringRes, toastLength, parameters -> + + }) + storage = overriddenStorage + } + + @Test + fun tryCreateGroup_shouldErrorOnEmptyName() { + val viewModel = createViewModel() + viewModel.tryCreateGroup() + assertNotNull(viewModel.viewState.value?.error) + } + + @Test + fun tryCreateGroup_shouldErrorOnEmptyMembers() { + val viewModel = createViewModel() + viewModel.tryCreateGroup() + assertNotNull(viewModel.viewState.value?.error) + } + + @Test + fun tryCreateGroup_shouldSucceedWithCorrectParameters() { + val viewModel = createViewModel() + assertNotNull(viewModel.tryCreateGroup()) + } + + private fun createViewModel() = CreateGroupViewModel(storage) + +} \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt b/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt new file mode 100644 index 000000000..e7a3ce107 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt @@ -0,0 +1,82 @@ +package network.loki.messenger.util + +import android.Manifest +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import com.adevinta.android.barista.interaction.PermissionGranter +import network.loki.messenger.R +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar +import org.thoughtcrime.securesms.mms.GlideApp + +fun setupLoggedInState(hasViewedSeed: Boolean = false) { + // landing activity + onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click()) + // session ID - register activity + onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click()) + // display name selection + onView(ViewMatchers.withId(R.id.displayNameEditText)) + .perform(ViewActions.typeText("test-user123")) + onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click()) + // PN select + if (hasViewedSeed) { + // has viewed seed is set to false after register activity + TextSecurePreferences.setHasViewedSeed( + InstrumentationRegistry.getInstrumentation().targetContext, + true + ) + } + onView(ViewMatchers.withId(R.id.backgroundPollingOptionView)) + .perform(ViewActions.click()) + onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click()) + // allow notification permission + PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) +} + +fun ConversationActivityV2.sendMessage(messageToSend: String, linkPreview: LinkPreview? = null) { + // assume in chat activity + onView( + Matchers.allOf( + ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.inputBar)), + ViewMatchers.withId(R.id.inputBarEditText) + ) + ).perform(ViewActions.replaceText(messageToSend)) + if (linkPreview != null) { + val glide = GlideApp.with(this) + this.findViewById(R.id.inputBar).updateLinkPreviewDraft(glide, linkPreview) + } + onView( + Matchers.allOf( + ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.inputBar)), + InputBarButtonDrawableMatcher.inputButtonWithDrawable(R.drawable.ic_arrow_up) + ) + ).perform(ViewActions.click()) + // TODO: text can flaky on cursor reload, figure out a better way to wait for the UI to settle with new data + onView(ViewMatchers.isRoot()).perform(waitFor(500)) +} + +/** + * Perform action of waiting for a specific time. + */ +fun waitFor(millis: Long): ViewAction { + return object : ViewAction { + override fun getConstraints(): Matcher? { + return ViewMatchers.isRoot() + } + + override fun getDescription(): String = "Wait for $millis milliseconds." + + override fun perform(uiController: UiController, view: View?) { + uiController.loopMainThreadForAtLeast(millis) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt b/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt new file mode 100644 index 000000000..7b02efad6 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt @@ -0,0 +1,31 @@ +package network.loki.messenger.util + +import androidx.test.platform.app.InstrumentationRegistry +import org.mockito.kotlin.spy +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.Storage +import kotlin.random.Random + +fun maybeGetUserInfo(): Pair? { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val prefs = appContext.prefs + val localUserPublicKey = prefs.getLocalNumber() + val secretKey = with(appContext) { + val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null + edKey.secretKey.asBytes + } + return if (localUserPublicKey == null || secretKey == null) null + else secretKey to localUserPublicKey +} + +fun ApplicationContext.applySpiedStorage(): Storage { + val storageSpy = spy(storage)!! + storage = storageSpy + return storageSpy +} + +fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } +fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) +fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2a4642b7..d0a6e63bc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -152,8 +152,13 @@ android:theme="@style/Theme.Session.DayNight.FlatActionBar" android:label="@string/blocked_contacts_title" /> + + + KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), configFactory - ); + ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); startKovenant(); @@ -282,13 +283,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO if (poller != null) { poller.stopIfNeeded(); } - ClosedGroupPollerV2.getShared().stopAll(); + pollerFactory.stopAll(); + LegacyClosedGroupPollerV2.getShared().stopAll(); } @Override public void onTerminate() { stopKovenant(); // Loki OpenGroupManager.INSTANCE.stopPolling(); + pollerFactory.stopAll(); super.onTerminate(); } @@ -437,7 +440,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.setUserPublicKey(userPublicKey); return; } - poller = new Poller(configFactory, new Timer()); + poller = new Poller(configFactory); } public void startPollingIfNeeded() { @@ -445,7 +448,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO if (poller != null) { poller.startIfNeeded(); } - ClosedGroupPollerV2.getShared().start(); + pollerFactory.startAll(); + LegacyClosedGroupPollerV2.getShared().start(); } private void resubmitProfilePictureIfNeeded() { @@ -500,9 +504,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } public void clearAllData(boolean isMigratingToV2KeyPair) { - if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) { - firebaseInstanceIdJob.cancel(null); - } String displayName = TextSecurePreferences.getProfileName(this); boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this); TextSecurePreferences.clearAll(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java deleted file mode 100644 index 0fd813cf4..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright (C) 2015 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms; - -import android.content.Context; -import androidx.annotation.NonNull; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; - - -import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; -import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; -import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; -import org.thoughtcrime.securesms.mms.GlideRequests; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.util.MediaUtil; - -import java.util.Collection; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; - -import network.loki.messenger.R; - -class MediaGalleryAdapter extends StickyHeaderGridAdapter { - - @SuppressWarnings("unused") - private static final String TAG = MediaGalleryAdapter.class.getSimpleName(); - - private final Context context; - private final GlideRequests glideRequests; - private final Locale locale; - private final ItemClickListener itemClickListener; - private final Set selected; - - private BucketedThreadMedia media; - - private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder { - ThumbnailView imageView; - View selectedIndicator; - - ViewHolder(View v) { - super(v); - imageView = v.findViewById(R.id.image); - selectedIndicator = v.findViewById(R.id.selected_indicator); - } - } - - private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder { - TextView textView; - - HeaderHolder(View itemView) { - super(itemView); - textView = itemView.findViewById(R.id.text); - } - } - - MediaGalleryAdapter(@NonNull Context context, - @NonNull GlideRequests glideRequests, - BucketedThreadMedia media, - Locale locale, - ItemClickListener clickListener) - { - this.context = context; - this.glideRequests = glideRequests; - this.locale = locale; - this.media = media; - this.itemClickListener = clickListener; - this.selected = new HashSet<>(); - } - - public void setMedia(BucketedThreadMedia media) { - this.media = media; - } - - @Override - public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) { - return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item_header, parent, false)); - } - - @Override - public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) { - return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item, parent, false)); - } - - @Override - public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) { - ((HeaderHolder)viewHolder).textView.setText(media.getName(section, locale)); - } - - @Override - public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) { - MediaRecord mediaRecord = media.get(section, offset); - ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView; - View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator; - Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); - - if (slide != null) { - thumbnailView.setImageResource(glideRequests, slide, false, null); - } - - thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); - thumbnailView.setOnLongClickListener(view -> { - itemClickListener.onMediaLongClicked(mediaRecord); - return true; - }); - - selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE); - } - - @Override - public int getSectionCount() { - return media.getSectionCount(); - } - - @Override - public int getSectionItemCount(int section) { - return media.getSectionItemCount(section); - } - - public void toggleSelection(@NonNull MediaRecord mediaRecord) { - if (!selected.remove(mediaRecord)) { - selected.add(mediaRecord); - } - notifyDataSetChanged(); - } - - public int getSelectedMediaCount() { - return selected.size(); - } - - @NonNull - public Collection getSelectedMedia() { - return new HashSet<>(selected); - } - - public void clearSelection() { - selected.clear(); - notifyDataSetChanged(); - } - - void selectAllMedia() { - for (int section = 0; section < media.getSectionCount(); section++) { - for (int item = 0; item < media.getSectionItemCount(section); item++) { - selected.add(media.get(section, item)); - } - } - this.notifyDataSetChanged(); - } - - interface ItemClickListener { - void onMediaClicked(@NonNull MediaRecord mediaRecord); - void onMediaLongClicked(MediaRecord mediaRecord); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.kt new file mode 100644 index 000000000..d36cc4828 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.kt @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.R +import network.loki.messenger.databinding.MediaOverviewGalleryItemBinding +import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.MediaUtil + +class MediaGalleryAdapter(private val itemClickListener: ItemClickListener): RecyclerView.Adapter() { + + private val items: MutableList = mutableListOf() + private val selectedItems: MutableSet = mutableSetOf() + + fun setItems(newItems: List) { + items.clear() + items += newItems + notifyDataSetChanged() + } + + fun toggleSelection(record: MediaRecord) { + val index = items.indexOf(record) + if (index >= 0) { + if (selectedItems.contains(record)) selectedItems -= record + else selectedItems += record + notifyItemChanged(index) + } + } + + fun getSelectedMediaCount() = selectedItems.size + + fun selectAllMedia() { + selectedItems += items + val size = items.size + notifyItemRangeChanged(0, size) + } + + fun getSelectedMedia() = selectedItems.toList() + + fun clearSelection() { + selectedItems.clear() + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.media_overview_gallery_item, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + holder.bind(item, selectedItems.contains(item), itemClickListener) + } + + override fun getItemCount(): Int = items.size + + class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + + private val binding = MediaOverviewGalleryItemBinding.bind(itemView) + private val glide = GlideApp.with(itemView) + + fun bind(item: MediaRecord, isSelected: Boolean, itemClickListener: ItemClickListener) { + val slide = MediaUtil.getSlideForAttachment(itemView.context, item.attachment) + + if (slide != null) { + binding.image.root.setImageResource(glide, slide, false, null) + } + + binding.image.root.setOnClickListener { itemClickListener.onMediaClicked(item) } + binding.image.root.setOnLongClickListener { + itemClickListener.onMediaLongClicked(item) + true + } + binding.selectedIndicator.isVisible = isSelected + } + } + + interface ItemClickListener { + fun onMediaClicked(mediaRecord: MediaRecord) + fun onMediaLongClicked(mediaRecord: MediaRecord?) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java index 95ba15c82..ae5824e86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java @@ -20,7 +20,6 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; -import android.content.res.Resources; import android.database.Cursor; import android.os.Build; import android.os.Bundle; @@ -35,7 +34,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; @@ -45,32 +43,32 @@ import androidx.fragment.app.FragmentStatePagerAdapter; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; import com.google.android.material.tabs.TabLayout; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.messages.control.DataExtractionNotification; import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; -import org.thoughtcrime.securesms.database.MediaDatabase; -import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; -import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; -import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.util.AttachmentUtil; -import org.thoughtcrime.securesms.util.SaveAttachmentTask; -import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.session.libsession.utilities.GroupRecord; +import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.ViewUtil; +import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.task.ProgressDialogAsyncTask; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.AttachmentUtil; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import java.util.ArrayList; import java.util.Collection; import java.util.LinkedList; import java.util.List; @@ -82,423 +80,450 @@ import network.loki.messenger.R; /** * Activity for displaying media attachments in-app */ -public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { +public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements View.OnClickListener { - @SuppressWarnings("unused") - private final static String TAG = MediaOverviewActivity.class.getSimpleName(); - - public static final String ADDRESS_EXTRA = "address"; - - private Toolbar toolbar; - private TabLayout tabLayout; - private ViewPager viewPager; - private Recipient recipient; - - @Override - protected void onCreate(Bundle bundle, boolean ready) { - setContentView(R.layout.media_overview_activity); - - initializeResources(); - initializeToolbar(); - - this.tabLayout.setupWithViewPager(viewPager); - this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager())); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - super.onOptionsItemSelected(item); - - switch (item.getItemId()) { - case android.R.id.home: finish(); return true; - } - - return false; - } - - private void initializeResources() { - Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA); - - this.viewPager = ViewUtil.findById(this, R.id.pager); - this.toolbar = ViewUtil.findById(this, R.id.toolbar); - this.tabLayout = ViewUtil.findById(this, R.id.tab_layout); - this.recipient = Recipient.from(this, address, true); - } - - private void initializeToolbar() { - setSupportActionBar(this.toolbar); - ActionBar actionBar = getSupportActionBar(); - actionBar.setTitle(recipient.toShortString()); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - this.recipient.addListener(recipient -> { - Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString())); - }); - } - - public void onEnterMultiSelect() { - tabLayout.setEnabled(false); - viewPager.setEnabled(false); - } - - public void onExitMultiSelect() { - tabLayout.setEnabled(true); - viewPager.setEnabled(true); - } - - private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter { - - MediaOverviewPagerAdapter(FragmentManager fragmentManager) { - super(fragmentManager); - } - - @Override - public Fragment getItem(int position) { - Fragment fragment; - - if (position == 0) fragment = new MediaOverviewGalleryFragment(); - else if (position == 1) fragment = new MediaOverviewDocumentsFragment(); - else throw new AssertionError(); - - Bundle args = new Bundle(); - args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize()); - args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault()); - - fragment.setArguments(args); - - return fragment; - } - - @Override - public int getCount() { - return 2; - } - - @Override - public CharSequence getPageTitle(int position) { - if (position == 0) return getString(R.string.MediaOverviewActivity_Media); - else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents); - else throw new AssertionError(); - } - } - - public static abstract class MediaOverviewFragment extends Fragment implements LoaderManager.LoaderCallbacks { + @SuppressWarnings("unused") + private final static String TAG = MediaOverviewActivity.class.getSimpleName(); public static final String ADDRESS_EXTRA = "address"; - public static final String LOCALE_EXTRA = "locale_extra"; - protected TextView noMedia; - protected Recipient recipient; - protected RecyclerView recyclerView; - protected Locale locale; + private Toolbar toolbar; + private TabLayout tabLayout; + private ViewPager viewPager; + private Recipient recipient; @Override - public void onCreate(Bundle bundle) { - super.onCreate(bundle); + protected void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.media_overview_activity); - String address = getArguments().getString(ADDRESS_EXTRA); - Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA); + initializeResources(); + initializeToolbar(); - if (address == null) throw new AssertionError(); - if (locale == null) throw new AssertionError(); - - this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true); - this.locale = locale; - - getLoaderManager().initLoader(0, null, this); - } - } - - public static class MediaOverviewGalleryFragment - extends MediaOverviewFragment - implements MediaGalleryAdapter.ItemClickListener - { - - private StickyHeaderGridLayoutManager gridManager; - private ActionMode actionMode; - private ActionModeCallback actionModeCallback = new ActionModeCallback(); - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false); - - this.recyclerView = ViewUtil.findById(view, R.id.media_grid); - this.noMedia = ViewUtil.findById(view, R.id.no_images); - this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); - - this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(), - GlideApp.with(this), - new BucketedThreadMedia(getContext()), - locale, - this)); - this.recyclerView.setLayoutManager(gridManager); - this.recyclerView.setHasFixedSize(true); - - return view; + this.tabLayout.setupWithViewPager(viewPager); + this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager())); } @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - if (gridManager != null) { - this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); - this.recyclerView.setLayoutManager(gridManager); - } + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + + return false; + } + + private void initializeResources() { + Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA); + + this.viewPager = ViewUtil.findById(this, R.id.pager); + this.toolbar = ViewUtil.findById(this, R.id.toolbar); + this.tabLayout = ViewUtil.findById(this, R.id.tab_layout); + this.recipient = Recipient.from(this, address, true); + } + + private void initializeToolbar() { + setSupportActionBar(this.toolbar); + ActionBar actionBar = getSupportActionBar(); + actionBar.setTitle(recipient.toShortString()); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setHomeButtonEnabled(true); + this.recipient.addListener(recipient -> { + Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString())); + }); + View clearButton = toolbar.findViewById(R.id.clearMedia); + if (!this.recipient.isClosedGroupRecipient()) { + clearButton.setVisibility(View.GONE); + } else { + String userPublicKey = TextSecurePreferences.getLocalNumber(this); + GroupRecord groupRecord = MessagingModuleConfiguration.getShared().getStorage().getGroup(this.recipient.getAddress().toGroupString()); + if (userPublicKey == null || groupRecord == null) { + clearButton.setVisibility(View.GONE); + } else { + boolean isUserAdmin = groupRecord.getAdmins().contains(Address.fromSerialized(userPublicKey)); + clearButton.setVisibility(isUserAdmin ? View.VISIBLE : View.GONE); + clearButton.setOnClickListener(this); + } + } + } + + public void onEnterMultiSelect() { + tabLayout.setEnabled(false); + viewPager.setEnabled(false); } @Override - public @NonNull Loader onCreateLoader(int i, Bundle bundle) { - return new BucketedThreadMediaLoader(getContext(), recipient.getAddress()); + public void onClick(View v) { + if (v.getId() == R.id.clearMedia) { + // TODO: future chunk + } } - @Override - public void onLoadFinished(@NonNull Loader loader, BucketedThreadMedia bucketedThreadMedia) { - ((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia); - ((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged(); - - noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE); - getActivity().invalidateOptionsMenu(); + public void onExitMultiSelect() { + tabLayout.setEnabled(true); + viewPager.setEnabled(true); } - @Override - public void onLoaderReset(@NonNull Loader cursorLoader) { - ((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext())); - } + private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter { - @Override - public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) { - if (actionMode != null) { - handleMediaMultiSelectClick(mediaRecord); - } else { - handleMediaPreviewClick(mediaRecord); - } - } + MediaOverviewPagerAdapter(FragmentManager fragmentManager) { + super(fragmentManager); + } - private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { - MediaGalleryAdapter adapter = getListAdapter(); - - adapter.toggleSelection(mediaRecord); - if (adapter.getSelectedMediaCount() == 0) { - actionMode.finish(); - } else { - actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount())); - } - } - - private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { - if (mediaRecord.getAttachment().getDataUri() == null) { - return; - } - - Context context = getContext(); - if (context == null) { - return; - } - - Intent intent = new Intent(context, MediaPreviewActivity.class); - intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); - intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize()); - intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress()); - intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing()); - intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true); - - intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType()); - context.startActivity(intent); - } - - @Override - public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) { - if (actionMode == null) { - ((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord); - recyclerView.getAdapter().notifyDataSetChanged(); - - enterMultiSelect(); - } - } - - @SuppressWarnings("CodeBlock2Expr") - @SuppressLint({"InlinedApi", "StaticFieldLeak"}) - private void handleSaveMedia(@NonNull Collection mediaRecords) { - final Context context = requireContext(); - - SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> { - Permissions.with(this) - .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) - .onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) - .onAllGranted(() -> { - new ProgressDialogAsyncTask>( - context, - R.string.MediaOverviewActivity_collecting_attachments, - R.string.please_wait) { - @Override - protected List doInBackground(Void... params) { - List attachments = new LinkedList<>(); - - for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) { - if (mediaRecord.getAttachment().getDataUri() != null) { - attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(), - mediaRecord.getContentType(), - mediaRecord.getDate(), - mediaRecord.getAttachment().getFileName())); - } - } - - return attachments; - } - - @Override - protected void onPostExecute(List attachments) { - super.onPostExecute(attachments); - SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size()); - saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR, - attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()])); - actionMode.finish(); - boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing()); - if (containsIncoming) { - sendMediaSavedNotificationIfNeeded(); - } - } - }.execute(); - }) - .execute(); - return Unit.INSTANCE; - }); - } - - private void sendMediaSavedNotificationIfNeeded() { - if (recipient.isGroupRecipient()) return; - DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); - MessageSender.send(message, recipient.getAddress()); - } - - @SuppressLint("StaticFieldLeak") - private void handleDeleteMedia(@NonNull Collection mediaRecords) { - int recordCount = mediaRecords.size(); - - DeleteMediaDialog.show( - requireContext(), - recordCount, - () -> new ProgressDialogAsyncTask( - requireContext(), - R.string.MediaOverviewActivity_Media_delete_progress_title, - R.string.MediaOverviewActivity_Media_delete_progress_message) { @Override - protected Void doInBackground(MediaDatabase.MediaRecord... records) { - if (records == null || records.length == 0) { - return null; - } + public Fragment getItem(int position) { + Fragment fragment; - for (MediaDatabase.MediaRecord record : records) { - AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); - } - return null; + if (position == 0) fragment = new MediaOverviewGalleryFragment(); + else if (position == 1) fragment = new MediaOverviewDocumentsFragment(); + else throw new AssertionError(); + + Bundle args = new Bundle(); + args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize()); + args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault()); + + fragment.setArguments(args); + + return fragment; } - }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]))); - } - private void handleSelectAllMedia() { - getListAdapter().selectAllMedia(); - actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount())); - } - - private MediaGalleryAdapter getListAdapter() { - return (MediaGalleryAdapter) recyclerView.getAdapter(); - } - - private void enterMultiSelect() { - actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback); - ((MediaOverviewActivity) getActivity()).onEnterMultiSelect(); - } - - private class ActionModeCallback implements ActionMode.Callback { - - private int originalStatusBarColor; - - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - mode.getMenuInflater().inflate(R.menu.media_overview_context, menu); - mode.setTitle("1"); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - Window window = getActivity().getWindow(); - originalStatusBarColor = window.getStatusBarColor(); - window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar)); + @Override + public int getCount() { + return 2; } - return true; - } - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; - } - - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { - switch (menuItem.getItemId()) { - case R.id.save: - handleSaveMedia(getListAdapter().getSelectedMedia()); - return true; - case R.id.delete: - handleDeleteMedia(getListAdapter().getSelectedMedia()); - actionMode.finish(); - return true; - case R.id.select_all: - handleSelectAllMedia(); - return true; + @Override + public CharSequence getPageTitle(int position) { + if (position == 0) return getString(R.string.MediaOverviewActivity_Media); + else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents); + else throw new AssertionError(); } - return false; - } + } - @Override - public void onDestroyActionMode(ActionMode mode) { - actionMode = null; - getListAdapter().clearSelection(); - ((MediaOverviewActivity) getActivity()).onExitMultiSelect(); + public static abstract class MediaOverviewFragment extends Fragment implements LoaderManager.LoaderCallbacks { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - getActivity().getWindow().setStatusBarColor(originalStatusBarColor); + public static final String ADDRESS_EXTRA = "address"; + public static final String LOCALE_EXTRA = "locale_extra"; + + protected TextView noMedia; + protected Recipient recipient; + protected RecyclerView recyclerView; + protected Locale locale; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + String address = getArguments().getString(ADDRESS_EXTRA); + Locale locale = (Locale) getArguments().getSerializable(LOCALE_EXTRA); + + if (address == null) throw new AssertionError(); + if (locale == null) throw new AssertionError(); + + this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true); + this.locale = locale; + + getLoaderManager().initLoader(0, null, this); } - } - } - } - - public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment { - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false); - MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale); - - this.recyclerView = ViewUtil.findById(view, R.id.recycler_view); - this.noMedia = ViewUtil.findById(view, R.id.no_documents); - - this.recyclerView.setAdapter(adapter); - this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false)); - this.recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, false, true)); - this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL)); - - return view; } - @Override - public @NonNull Loader onCreateLoader(int id, Bundle args) { - return new ThreadMediaLoader(getContext(), recipient.getAddress(), false); + public static class MediaOverviewGalleryFragment + extends MediaOverviewFragment + implements MediaGalleryAdapter.ItemClickListener { + + private GridLayoutManager gridManager; + private ActionMode actionMode; + private ActionModeCallback actionModeCallback = new ActionModeCallback(); + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false); + + this.recyclerView = ViewUtil.findById(view, R.id.media_grid); + this.noMedia = ViewUtil.findById(view, R.id.no_images); + this.gridManager = new GridLayoutManager(getContext(), getResources().getInteger(R.integer.media_overview_cols)); + + this.recyclerView.setAdapter(new MediaGalleryAdapter(this)); + this.recyclerView.setLayoutManager(gridManager); + this.recyclerView.setHasFixedSize(true); + + return view; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (gridManager != null) { + this.gridManager = new GridLayoutManager(getContext(), getResources().getInteger(R.integer.media_overview_cols)); + this.recyclerView.setLayoutManager(gridManager); + } + } + + @Override + public @NonNull + Loader onCreateLoader(int i, Bundle bundle) { + return new ThreadMediaLoader(requireContext(), recipient.getAddress(), true); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor cursor) { + List mediaRecords = new ArrayList<>(); + if (cursor != null && cursor.moveToFirst()) { + do { + mediaRecords.add(MediaDatabase.MediaRecord.from(requireContext(), cursor)); + } while (cursor.moveToNext()); + } + + MediaGalleryAdapter adapter = getListAdapter(); + adapter.setItems(mediaRecords); + + noMedia.setVisibility(adapter.getItemCount() > 0 ? View.GONE : View.VISIBLE); + requireActivity().invalidateOptionsMenu(); + } + + @Override + public void onLoaderReset(@NonNull Loader cursorLoader) { + getListAdapter().setItems(new ArrayList<>()); + } + + @Override + public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) { + if (actionMode != null) { + handleMediaMultiSelectClick(mediaRecord); + } else { + handleMediaPreviewClick(mediaRecord); + } + } + + private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { + MediaGalleryAdapter adapter = getListAdapter(); + + adapter.toggleSelection(mediaRecord); + if (adapter.getSelectedMediaCount() == 0) { + actionMode.finish(); + } else { + actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount())); + } + } + + private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { + if (mediaRecord.getAttachment().getDataUri() == null) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); + intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize()); + intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress()); + intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing()); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true); + + intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType()); + context.startActivity(intent); + } + + @Override + public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) { + if (actionMode == null) { + ((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord); + recyclerView.getAdapter().notifyDataSetChanged(); + + enterMultiSelect(); + } + } + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint({"InlinedApi", "StaticFieldLeak"}) + private void handleSaveMedia(@NonNull Collection mediaRecords) { + final Context context = requireContext(); + + SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> { + Permissions.with(this) + .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + .maxSdkVersion(Build.VERSION_CODES.P) + .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .onAllGranted(() -> { + new ProgressDialogAsyncTask>( + context, + R.string.MediaOverviewActivity_collecting_attachments, + R.string.please_wait) { + @Override + protected List doInBackground(Void... params) { + List attachments = new LinkedList<>(); + + for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) { + if (mediaRecord.getAttachment().getDataUri() != null) { + attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(), + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.getAttachment().getFileName())); + } + } + + return attachments; + } + + @Override + protected void onPostExecute(List attachments) { + super.onPostExecute(attachments); + SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size()); + saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR, + attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()])); + actionMode.finish(); + boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing()); + if (containsIncoming) { + sendMediaSavedNotificationIfNeeded(); + } + } + }.execute(); + }) + .execute(); + return Unit.INSTANCE; + }); + } + + private void sendMediaSavedNotificationIfNeeded() { + if (recipient.isGroupRecipient()) return; + DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); + MessageSender.send(message, recipient.getAddress()); + } + + @SuppressLint("StaticFieldLeak") + private void handleDeleteMedia(@NonNull Collection mediaRecords) { + int recordCount = mediaRecords.size(); + + DeleteMediaDialog.show( + requireContext(), + recordCount, + () -> + new ProgressDialogAsyncTask(requireContext(), + R.string.MediaOverviewActivity_Media_delete_progress_title, + R.string.MediaOverviewActivity_Media_delete_progress_message) { + @Override + protected Void doInBackground(MediaDatabase.MediaRecord... records) { + if (records == null || records.length == 0) { + return null; + } + + for (MediaDatabase.MediaRecord record : records) { + AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); + } + return null; + } + + }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]))); + } + + private void handleSelectAllMedia() { + getListAdapter().selectAllMedia(); + actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount())); + } + + private MediaGalleryAdapter getListAdapter() { + return (MediaGalleryAdapter) recyclerView.getAdapter(); + } + + private void enterMultiSelect() { + actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback); + ((MediaOverviewActivity) getActivity()).onEnterMultiSelect(); + } + + private class ActionModeCallback implements ActionMode.Callback { + + private int originalStatusBarColor; + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.media_overview_context, menu); + mode.setTitle("1"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Window window = getActivity().getWindow(); + originalStatusBarColor = window.getStatusBarColor(); + window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar)); + } + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.save: + handleSaveMedia(getListAdapter().getSelectedMedia()); + return true; + case R.id.delete: + handleDeleteMedia(getListAdapter().getSelectedMedia()); + actionMode.finish(); + return true; + case R.id.select_all: + handleSelectAllMedia(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + getListAdapter().clearSelection(); + ((MediaOverviewActivity) getActivity()).onExitMultiSelect(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getActivity().getWindow().setStatusBarColor(originalStatusBarColor); + } + } + } } - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data); - getActivity().invalidateOptionsMenu(); + public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment { - this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE); - } + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false); + MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale); - @Override - public void onLoaderReset(@NonNull Loader loader) { - ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null); - getActivity().invalidateOptionsMenu(); + this.recyclerView = ViewUtil.findById(view, R.id.recycler_view); + this.noMedia = ViewUtil.findById(view, R.id.no_documents); + + this.recyclerView.setAdapter(adapter); + this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), RecyclerView.VERTICAL, false)); + this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL)); + + return view; + } + + @Override + public @NonNull + Loader onCreateLoader(int id, Bundle args) { + return new ThreadMediaLoader(getContext(), recipient.getAddress(), false); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor data) { + ((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(data); + getActivity().invalidateOptionsMenu(); + + this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + ((CursorRecyclerViewAdapter) this.recyclerView.getAdapter()).changeCursor(null); + getActivity().invalidateOptionsMenu(); + } } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 7635ad40e..52748b429 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -53,7 +53,7 @@ class ProfilePictureView @JvmOverloads constructor( return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } - if (recipient.isClosedGroupRecipient) { + if (recipient.isLegacyClosedGroupRecipient) { val members = DatabaseComponent.get(context).groupDatabase() .getGroupMemberAddresses(recipient.address.toGroupString(), true) .sorted() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt index 3a2b2cbb5..34e25af0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St private fun getClosedGroups(contacts: List): List { return getItems(contacts, context.getString(R.string.fragment_contact_selection_closed_groups_title)) { - it.address.isClosedGroup + it.address.isLegacyClosedGroup || it.address.isClosedGroup } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt new file mode 100644 index 000000000..49ce12673 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.conversation.settings + +import android.os.Bundle +import android.view.View +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.databinding.ActivityConversationNotificationSettingsBinding +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import javax.inject.Inject + +@AndroidEntryPoint +class ConversationNotificationSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener { + + lateinit var binding: ActivityConversationNotificationSettingsBinding + @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var recipientDb: RecipientDatabase + val recipient by lazy { + if (threadId == -1L) null + else threadDb.getRecipientForThreadId(threadId) + } + var threadId: Long = -1 + + override fun onClick(v: View?) { + val recipient = recipient ?: return + if (v === binding.notifyAll) { + // set notify type + recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_ALL) + } else if (v === binding.notifyMentions) { + recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_MENTIONS) + } else if (v === binding.notifyMute) { + recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_NONE) + } + updateValues() + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + binding = ActivityConversationNotificationSettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + threadId = intent.getLongExtra(ConversationActivityV2.THREAD_ID, -1L) + if (threadId == -1L) finish() + updateValues() + with (binding) { + notifyAll.setOnClickListener(this@ConversationNotificationSettingsActivity) + notifyMentions.setOnClickListener(this@ConversationNotificationSettingsActivity) + notifyMute.setOnClickListener(this@ConversationNotificationSettingsActivity) + } + } + + private fun updateValues() { + val notifyType = recipient?.notifyType ?: return + binding.notifyAllButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_ALL + binding.notifyMentionsButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS + binding.notifyMuteButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_NONE + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt new file mode 100644 index 000000000..d55d0b249 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.conversation.settings + +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 + +class ConversationNotificationSettingsActivityContract: ActivityResultContract() { + + override fun createIntent(context: Context, input: Long): Intent = + Intent(context, ConversationNotificationSettingsActivity::class.java).apply { + putExtra(ConversationActivityV2.THREAD_ID, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?) { /* do nothing */ } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt new file mode 100644 index 000000000..39c3461eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt @@ -0,0 +1,219 @@ +package org.thoughtcrime.securesms.conversation.settings + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.view.View +import androidx.activity.viewModels +import androidx.core.text.set +import androidx.core.view.isVisible +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityConversationSettingsBinding +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.MediaOverviewActivity +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.groups.EditClosedGroupActivity +import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity +import org.thoughtcrime.securesms.showSessionDialog +import javax.inject.Inject + +@AndroidEntryPoint +class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener { + + companion object { + // used to trigger displaying conversation search in calling parent activity + const val RESULT_SEARCH = 22 + } + + lateinit var binding: ActivityConversationSettingsBinding + + private val groupOptions: List + get() = with(binding) { + listOf( + groupMembers, + groupMembersDivider.root, + editGroup, + editGroupDivider.root, + leaveGroup, + leaveGroupDivider.root + ) + } + + @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var lokiThreadDb: LokiThreadDatabase + @Inject lateinit var viewModelFactory: ConversationSettingsViewModel.AssistedFactory + val viewModel: ConversationSettingsViewModel by viewModels { + val threadId = intent.getLongExtra(ConversationActivityV2.THREAD_ID, -1L) + if (threadId == -1L) { + finish() + } + viewModelFactory.create(threadId) + } + + private val notificationActivityCallback = registerForActivityResult(ConversationNotificationSettingsActivityContract()) { + updateRecipientDisplay() + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + binding = ActivityConversationSettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + updateRecipientDisplay() + binding.searchConversation.setOnClickListener(this) + binding.clearMessages.setOnClickListener(this) + binding.allMedia.setOnClickListener(this) + binding.pinConversation.setOnClickListener(this) + binding.notificationSettings.setOnClickListener(this) + binding.editGroup.setOnClickListener(this) + binding.addAdmins.setOnClickListener(this) + binding.leaveGroup.setOnClickListener(this) + binding.back.setOnClickListener(this) + binding.autoDownloadMediaSwitch.setOnCheckedChangeListener { _, isChecked -> + viewModel.setAutoDownloadAttachments(isChecked) + updateRecipientDisplay() + } + } + + private fun updateRecipientDisplay() { + val recipient = viewModel.recipient ?: return + // Setup profile image + binding.profilePictureView.root.update(recipient) + // Setup name + binding.conversationName.text = when { + recipient.isLocalNumber -> getString(R.string.note_to_self) + else -> recipient.toShortString() + } + // Setup group description (if group) + binding.conversationSubtitle.isVisible = recipient.isClosedGroupRecipient.apply { + binding.conversationSubtitle.text = viewModel.closedGroupInfo(recipient.address.serialize())?.description + } + + // Toggle group-specific settings + val areGroupOptionsVisible = recipient.isClosedGroupRecipient || recipient.isLegacyClosedGroupRecipient + groupOptions.forEach { v -> + v.isVisible = areGroupOptionsVisible + } + + // Group admin settings + val isUserGroupAdmin = areGroupOptionsVisible && viewModel.isUserGroupAdmin() + with (binding) { + groupMembersDivider.root.isVisible = areGroupOptionsVisible && !isUserGroupAdmin + groupMembers.isVisible = !isUserGroupAdmin + adminControlsGroup.isVisible = isUserGroupAdmin + deleteGroup.isVisible = isUserGroupAdmin + clearMessages.isVisible = isUserGroupAdmin + clearMessagesDivider.root.isVisible = isUserGroupAdmin + leaveGroupDivider.root.isVisible = isUserGroupAdmin + } + + // Set pinned state + binding.pinConversation.setText( + if (viewModel.isPinned()) R.string.conversation_settings_unpin_conversation + else R.string.conversation_settings_pin_conversation + ) + + // Set auto-download state + val trusted = viewModel.autoDownloadAttachments() + binding.autoDownloadMediaSwitch.isChecked = trusted + + // Set notification type + val notifyTypes = resources.getStringArray(R.array.notify_types) + val summary = notifyTypes.getOrNull(recipient.notifyType) + binding.notificationsValue.text = summary + } + + override fun onClick(v: View?) { + when { + v === binding.searchConversation -> { + setResult(RESULT_SEARCH) + finish() + } + v === binding.allMedia -> { + val threadRecipient = viewModel.recipient ?: return + val intent = Intent(this, MediaOverviewActivity::class.java).apply { + putExtra(MediaOverviewActivity.ADDRESS_EXTRA, threadRecipient.address) + } + startActivity(intent) + } + v === binding.pinConversation -> { + viewModel.togglePin().invokeOnCompletion { e -> + if (e != null) { + // something happened + Log.e("ConversationSettings", "Failed to toggle pin on thread", e) + } else { + updateRecipientDisplay() + } + } + } + v === binding.notificationSettings -> { + notificationActivityCallback.launch(viewModel.threadId) + } + v === binding.back -> onBackPressed() + v === binding.clearMessages -> { + + showSessionDialog { + title(R.string.dialog_clear_all_messages_title) + text(R.string.dialog_clear_all_messages_message) + destructiveButton( + R.string.dialog_clear_all_messages_clear, + R.string.dialog_clear_all_messages_clear) { + viewModel.clearMessages(false) + } + cancelButton() + } + } + v === binding.leaveGroup -> { + showSessionDialog { + + title(R.string.conversation_settings_leave_group) + + val name = viewModel.recipient!!.name!! + val text = getString(R.string.conversation_settings_leave_group_name) + val textWithArgs = getString(R.string.conversation_settings_leave_group_name, name) + + // Searches for the start index of %1$s + val startIndex = """%1${"\\$"}s""".toRegex().find(text)?.range?.start + val endIndex = startIndex?.plus(name.length) + + val styledText = if (startIndex == null || endIndex == null) { + textWithArgs + } else { + val boldName = SpannableStringBuilder(textWithArgs) + boldName[startIndex .. endIndex] = StyleSpan(Typeface.BOLD) + boldName + } + text(styledText) + destructiveButton( + R.string.conversation_settings_leave_group, + R.string.conversation_settings_leave_group + ) { + viewModel.leaveGroup() + } + } + } + v === binding.editGroup -> { + val recipient = viewModel.recipient ?: return + + val intent = when { + recipient.isLegacyClosedGroupRecipient -> Intent(this, EditLegacyClosedGroupActivity::class.java).apply { + val groupID: String = recipient.address.toGroupString() + putExtra(EditLegacyClosedGroupActivity.groupIDKey, groupID) + } + recipient.isClosedGroupRecipient -> Intent(this, EditClosedGroupActivity::class.java).apply { + val groupID = recipient.address.serialize() + putExtra(EditClosedGroupActivity.groupIDKey, groupID) + } + + else -> return + } + startActivity(intent) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt new file mode 100644 index 000000000..a79d94b3a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.conversation.settings + +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 + +sealed class ConversationSettingsActivityResult { + object Finished: ConversationSettingsActivityResult() + object SearchConversation: ConversationSettingsActivityResult() +} + +class ConversationSettingsActivityContract: ActivityResultContract() { + + override fun createIntent(context: Context, input: Long) = Intent(context, ConversationSettingsActivity::class.java).apply { + putExtra(ConversationActivityV2.THREAD_ID, input ?: -1L) + } + + override fun parseResult(resultCode: Int, intent: Intent?): ConversationSettingsActivityResult = + when (resultCode) { + ConversationSettingsActivity.RESULT_SEARCH -> ConversationSettingsActivityResult.SearchConversation + else -> ConversationSettingsActivityResult.Finished + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt new file mode 100644 index 000000000..619cb71dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.conversation.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.util.GroupDisplayInfo +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences + +class ConversationSettingsViewModel( + val threadId: Long, + private val storage: StorageProtocol, + private val prefs: TextSecurePreferences +): ViewModel() { + + val recipient get() = storage.getRecipientForThread(threadId) + + fun isPinned() = storage.isPinned(threadId) + + fun togglePin() = viewModelScope.launch { + val isPinned = storage.isPinned(threadId) + storage.setPinned(threadId, !isPinned) + } + + fun autoDownloadAttachments() = recipient?.let { recipient -> storage.shouldAutoDownloadAttachments(recipient) } ?: false + + fun setAutoDownloadAttachments(shouldDownload: Boolean) { + recipient?.let { recipient -> storage.setAutoDownloadAttachments(recipient, shouldDownload) } + } + + fun isUserGroupAdmin(): Boolean = recipient?.let { recipient -> + when { + recipient.isLegacyClosedGroupRecipient -> { + val localUserAddress = prefs.getLocalNumber() ?: return@let false + val group = storage.getGroup(recipient.address.toGroupString()) + group?.admins?.contains(Address.fromSerialized(localUserAddress)) ?: false // this will have to be replaced for new closed groups + } + recipient.isClosedGroupRecipient -> { + val group = storage.getLibSessionClosedGroup(recipient.address.serialize()) ?: return@let false + group.hasAdminKey() + } + else -> false + } + } ?: false + + fun clearMessages(forAll: Boolean) { + if (forAll && !isUserGroupAdmin()) return + + if (!forAll) { + viewModelScope.launch { + storage.clearMessages(threadId) + } + } else { + // do a send message here and on success do a clear messages + viewModelScope.launch { + storage.clearMessages(threadId) + } + } + } + + fun closedGroupInfo(address: String): GroupDisplayInfo? = storage.getClosedGroupDisplayInfo(address) + + fun leaveGroup() { + viewModelScope.launch { + storage.leaveGroup(recipient!!.address.serialize()) + } + } + + // DI-related + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(threadId: Long): Factory + } + class Factory @AssistedInject constructor( + @Assisted private val threadId: Long, + private val storage: StorageProtocol, + private val prefs: TextSecurePreferences + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return ConversationSettingsViewModel(threadId, storage, prefs) as T + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 70d83ef5e..4730f147a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -66,6 +66,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ViewVisibleMessageBinding import nl.komponents.kovenant.ui.successUi +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob @@ -83,7 +84,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized @@ -98,6 +98,7 @@ import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.SessionId import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext @@ -105,6 +106,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey +import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract +import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP @@ -141,7 +144,6 @@ import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -198,7 +200,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, - ConversationMenuHelper.ConversationMenuListener { + ConversationMenuHelper.ConversationMenuListener, View.OnClickListener { private var binding: ActivityConversationV2Binding? = null @@ -213,7 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase - @Inject lateinit var storage: Storage + @Inject lateinit var storage: StorageProtocol @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @@ -224,6 +226,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result -> + if (result is ConversationSettingsActivityResult.SearchConversation) { + // open search + binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView() + } + } + private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val linkPreviewViewModel: LinkPreviewViewModel by lazy { ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) @@ -238,7 +247,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val sessionId = SessionId(it.serialize()) val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { - storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let { + storage.getOrCreateBlindedIdMapping(sessionId.hexString(), openGroup.server, openGroup.publicKey).sessionId?.let { fromSerialized(it) } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId) } else { @@ -361,7 +370,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 const val INVITE_CONTACTS = 124 - + const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result } // endregion @@ -414,6 +423,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) + binding!!.toolbarContent.profilePictureView.setOnClickListener(this) updateSendAfterApprovalText() showOrHideInputIfNeeded() setUpMessageRequestsBar() @@ -578,7 +588,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe recipient.isLocalNumber -> getString(R.string.note_to_self) else -> recipient.toShortString() } - @DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { + @DimenRes val sizeID: Int = if (viewModel.recipient?.isLegacyClosedGroupRecipient == true) { R.dimen.medium_profile_picture_size } else { R.dimen.small_profile_picture_size @@ -810,8 +820,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun showOrHideInputIfNeeded() { - val recipient = viewModel.recipient - if (recipient != null && recipient.isClosedGroupRecipient) { + val recipient = viewModel.recipient ?: return + if (recipient.isLegacyClosedGroupRecipient) { val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() val isActive = (group?.isActive == true) binding?.inputBar?.showInput = isActive @@ -821,8 +831,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun setUpMessageRequestsBar() { + val recipient = viewModel.recipient ?: return binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread() binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread() + binding?.sendAcceptsTextView?.setText( + if (recipient.isClosedGroupRecipient) R.string.message_requests_send_group_notice + else R.string.message_requests_send_notice + ) binding?.acceptMessageRequestButton?.setOnClickListener { acceptMessageRequest() } @@ -856,11 +871,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun isIncomingMessageRequestThread(): Boolean { val recipient = viewModel.recipient ?: return false - return !recipient.isGroupRecipient && + return !recipient.isLegacyClosedGroupRecipient && + !recipient.isOpenGroupRecipient && !recipient.isApproved && !recipient.isLocalNumber && !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && - threadDb.getMessageCount(viewModel.threadId) > 0 + (threadDb.getMessageCount(viewModel.threadId) > 0 || recipient.isClosedGroupRecipient) } override fun inputBarEditTextContentChanged(newContent: CharSequence) { @@ -1116,14 +1132,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) } } else if (recipient.isGroupRecipient) { - viewModel.openGroup?.let { openGroup -> - val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount) - } ?: run { - val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) + when { + recipient.isOpenGroupRecipient -> { + viewModel.openGroup?.let { openGroup -> + val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount) + } + } + recipient.isLegacyClosedGroupRecipient -> { + val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) + } + recipient.isClosedGroupRecipient -> { + val userCount = viewModel.closedGroupMembers.size + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) + } } - viewModel } else { actionBarBinding.conversationSubtitleView.isVisible = false } @@ -1140,6 +1164,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } ?: false } + override fun onClick(v: View?) { + if (v === binding?.toolbarContent?.profilePictureView) { + // open conversation settings + conversationSettingsCallback.launch(viewModel.threadId) + } + } + override fun block(deleteThread: Boolean) { showSessionDialog { title(R.string.RecipientPreferenceActivity_block_this_contact_question) @@ -1174,8 +1205,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } + // TODO: don't need to allow new closed group check here, removed in new disappearing messages override fun showExpiringMessagesDialog(thread: Recipient) { - if (thread.isClosedGroupRecipient) { + if (thread.isLegacyClosedGroupRecipient) { val group = groupDb.getGroup(thread.address.toGroupString()).orNull() if (group?.isActive == false) { return } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 532e65e19..9b25db1d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -12,14 +12,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.util.GroupMember +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.Storage +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository import java.util.UUID @@ -28,7 +29,7 @@ class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage + private val storage: StorageProtocol ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -58,19 +59,27 @@ class ConversationViewModel( val openGroup: OpenGroup? get() = _openGroup.value + val closedGroupMembers: List + get() { + val recipient = recipient ?: return emptyList() + if (!recipient.isClosedGroupRecipient) return emptyList() + return storage.getMembers(recipient.address.serialize()) + } + + val serverCapabilities: List get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf() val blindedPublicKey: String? get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes - ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString + ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString() } val isMessageRequestThread : Boolean get() { val recipient = recipient ?: return false - return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved + return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isOpenGroupRecipient && !recipient.isApproved } val canReactToMessages: Boolean @@ -186,7 +195,8 @@ class ConversationViewModel( } fun declineMessageRequest() { - repository.declineMessageRequest(threadId) + val recipient = recipient ?: return + repository.declineMessageRequest(threadId, recipient) } private fun showMessage(message: String) { @@ -228,7 +238,7 @@ class ConversationViewModel( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage + private val storage: StorageProtocol ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index b6212b854..c05f5d926 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen if (!this::recipient.isInitialized) { return dismiss() } - if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { + if (recipient.isLocalNumber) { + binding.deleteForEveryoneTextView.text = + getString(R.string.delete_message_for_my_devices) + } else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { binding.deleteForEveryoneTextView.text = resources.getString(R.string.delete_message_for_me_and_recipient, contact) } - binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient + binding.deleteForEveryoneTextView.isVisible = !recipient.isLegacyClosedGroupRecipient binding.deleteForMeTextView.setOnClickListener(this) binding.deleteForEveryoneTextView.setOnClickListener(this) binding.cancelTextView.setOnClickListener(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 61732827f..c316a2ae5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -55,6 +55,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.session.libsession.database.StorageProtocol import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.Storage @@ -81,7 +82,7 @@ import javax.inject.Inject class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Inject - lateinit var storage: Storage + lateinit var storage: StorageProtocol private val viewModel: MessageDetailsViewModel by viewModels() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 5edd63f10..fd49e501b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -9,44 +9,53 @@ import android.text.style.StyleSpan import androidx.fragment.app.DialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject /** Shown when receiving media from a contact for the first time, to confirm that * they are to be trusted and files sent by them are to be downloaded. */ @AndroidEntryPoint -class DownloadDialog(private val recipient: Recipient) : DialogFragment() { +class AutoDownloadDialog(private val threadRecipient: Recipient, + private val databaseAttachment: DatabaseAttachment +) : DialogFragment() { + @Inject lateinit var storage: StorageProtocol @Inject lateinit var contactDB: SessionContactDatabase override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - val sessionID = recipient.address.toString() - val contact = contactDB.getContactWithSessionID(sessionID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - title(resources.getString(R.string.dialog_download_title, name)) + val threadId = storage.getThreadId(threadRecipient) ?: run { + dismiss() + return@createSessionDialog + } - val explanation = resources.getString(R.string.dialog_download_explanation, name) + val displayName = when { + threadRecipient.isOpenGroupRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN" + threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN" + threadRecipient.isClosedGroupRecipient -> threadRecipient.name ?: "UNKNOWN" + else -> storage.getContactWithSessionID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN" + } + title(resources.getString(R.string.dialog_auto_download_title)) + + val explanation = resources.getString(R.string.dialog_auto_download_explanation, displayName) val spannable = SpannableStringBuilder(explanation) - val startIndex = explanation.indexOf(name) - spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val startIndex = explanation.indexOf(displayName) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text(spannable) - button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() } - cancelButton { dismiss() } + button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { + setAutoDownload(true) + } + cancelButton { + setAutoDownload(false) + } } - private fun trust() { - val sessionID = recipient.address.toString() - val contact = contactDB.getContactWithSessionID(sessionID) ?: return - val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) - contactDB.setContactIsTrusted(contact, true, threadID) - JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) - dismiss() + private fun setAutoDownload(shouldDownload: Boolean) { + storage.setAutoDownloadAttachments(threadRecipient, shouldDownload) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 3746aa52e..7c94a6230 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -6,10 +6,10 @@ import android.view.Menu import android.view.MenuItem import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord @@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } - ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString + ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString() fun userCanDeleteSelectedItems(): Boolean { val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 02ee4ae45..b855e411a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -38,8 +38,8 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.EditClosedGroupActivity -import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey +import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity +import org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.showSessionDialog @@ -63,7 +63,7 @@ object ConversationMenuHelper { // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { + if (!isOpenGroup && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient) && !thread.isBlocked) { if (thread.expireMessages > 0) { inflater.inflate(R.menu.menu_conversation_expiration_on, menu) val item = menu.findItem(R.id.menu_expiring_messages) @@ -92,7 +92,7 @@ object ConversationMenuHelper { } } // Closed group menu (options that should only be present in closed groups) - if (thread.isClosedGroupRecipient) { + if (thread.isLegacyClosedGroupRecipient) { inflater.inflate(R.menu.menu_conversation_closed_group, menu) } // Open group menu @@ -280,15 +280,15 @@ object ConversationMenuHelper { } private fun editClosedGroup(context: Context, thread: Recipient) { - if (!thread.isClosedGroupRecipient) { return } - val intent = Intent(context, EditClosedGroupActivity::class.java) + if (!thread.isLegacyClosedGroupRecipient) { return } + val intent = Intent(context, EditLegacyClosedGroupActivity::class.java) val groupID: String = thread.address.toGroupString() intent.putExtra(groupIDKey, groupID) context.startActivity(intent) } private fun leaveClosedGroup(context: Context, thread: Recipient) { - if (!thread.isClosedGroupRecipient) { return } + if (!thread.isLegacyClosedGroupRecipient) { return } val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val admins = group.admins diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt new file mode 100644 index 000000000..afdf84240 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewPendingAttachmentBinding +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.dialogs.AutoDownloadDialog +import org.thoughtcrime.securesms.util.ActivityDispatcher +import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload +import org.thoughtcrime.securesms.util.displaySize +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class PendingAttachmentView: LinearLayout { + private val binding by lazy { ViewPendingAttachmentBinding.bind(this) } + enum class AttachmentType { + AUDIO, + DOCUMENT, + IMAGE, + VIDEO, + } + + // region Lifecycle + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + // endregion + @Inject lateinit var storage: StorageProtocol + + // region Updating + fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int, attachment: DatabaseAttachment) { + val stringRes = when (attachmentType) { + AttachmentType.AUDIO -> R.string.Slide_audio + AttachmentType.DOCUMENT -> R.string.document + AttachmentType.IMAGE -> R.string.image + AttachmentType.VIDEO -> R.string.video + } + + val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).lowercase(Locale.ROOT)) + + binding.pendingDownloadIcon.setColorFilter(textColor) + binding.pendingDownloadSize.text = attachment.displaySize() + binding.pendingDownloadTitle.text = text + } + // endregion + + // region Interaction + fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) { + JobQueue.shared.createAndStartAttachmentDownload(attachment) + if (!storage.hasAutoDownloadFlagBeenSet(threadRecipient)) { + // just download + ActivityDispatcher.get(context)?.showDialog(AutoDownloadDialog(threadRecipient, attachment)) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt deleted file mode 100644 index 47034cf8e..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.messages - -import android.content.Context -import android.util.AttributeSet -import android.widget.LinearLayout -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import network.loki.messenger.R -import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog -import org.thoughtcrime.securesms.util.ActivityDispatcher -import java.util.Locale - -class UntrustedAttachmentView: LinearLayout { - private val binding: ViewUntrustedAttachmentBinding by lazy { ViewUntrustedAttachmentBinding.bind(this) } - enum class AttachmentType { - AUDIO, - DOCUMENT, - MEDIA - } - - // region Lifecycle - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - // endregion - - // region Updating - fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int) { - val (iconRes, stringRes) = when (attachmentType) { - AttachmentType.AUDIO -> R.drawable.ic_microphone to R.string.Slide_audio - AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.document - AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media - } - val iconDrawable = ContextCompat.getDrawable(context,iconRes)!! - iconDrawable.mutate().setTint(textColor) - val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).toLowerCase(Locale.ROOT)) - - binding.untrustedAttachmentIcon.setImageDrawable(iconDrawable) - binding.untrustedAttachmentTitle.text = text - } - // endregion - - // region Interaction - fun showTrustDialog(recipient: Recipient) { - ActivityDispatcher.get(context)?.showDialog(DownloadDialog(recipient)) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index c812d0f73..1e47ba075 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -64,7 +64,6 @@ class VisibleMessageContentView : ConstraintLayout { glide: GlideRequests = GlideApp.with(this), thread: Recipient, searchQuery: String? = null, - contactIsTrusted: Boolean = true, onAttachmentNeedsDownload: (Long, Long) -> Unit, suppressThumbnails: Boolean = false ) { @@ -74,8 +73,9 @@ class VisibleMessageContentView : ConstraintLayout { binding.contentParent.mainColor = color binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) - val onlyBodyMessage = message is SmsMessageRecord - val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null + val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE } + val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress } + val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null // reset visibilities / containers onContentClick.clear() @@ -88,7 +88,6 @@ class VisibleMessageContentView : ConstraintLayout { binding.bodyTextView.isVisible = false binding.quoteView.root.isVisible = false binding.linkPreviewView.root.isVisible = false - binding.untrustedView.root.isVisible = false binding.voiceMessageView.root.isVisible = false binding.documentView.root.isVisible = false binding.albumThumbnailView.root.isVisible = false @@ -103,9 +102,9 @@ class VisibleMessageContentView : ConstraintLayout { binding.bodyTextView.text = null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() - binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() - binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null - binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null + binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() + binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null + binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation @@ -151,6 +150,7 @@ class VisibleMessageContentView : ConstraintLayout { } when { + // LINK PREVIEW message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } @@ -158,10 +158,11 @@ class VisibleMessageContentView : ConstraintLayout { // When in a link preview ensure the bodyTextView can expand to the full width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width } + // AUDIO message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { hideBody = true // Audio attachment - if (contactIsTrusted || message.isOutgoing) { + if (mediaDownloaded || mediaInProgress || message.isOutgoing) { binding.voiceMessageView.root.indexInAdapter = indexInAdapter binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) @@ -170,26 +171,38 @@ class VisibleMessageContentView : ConstraintLayout { onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } } else { - // TODO: move this out to its own area - binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } + hideBody = true + (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> + binding.pendingAttachmentView.root.bind( + PendingAttachmentView.AttachmentType.AUDIO, + getTextColor(context,message), + attachment + ) + onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } + } } } + // DOCUMENT message is MmsMessageRecord && message.slideDeck.documentSlide != null -> { - hideBody = true + hideBody = true // TODO: check if this is still the logic we want // Document attachment - if (contactIsTrusted || message.isOutgoing) { - binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) + if (mediaDownloaded || mediaInProgress || message.isOutgoing) { + binding.documentView.root.bind(message, getTextColor(context, message)) } else { - binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } + hideBody = true + (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> + binding.pendingAttachmentView.root.bind( + PendingAttachmentView.AttachmentType.DOCUMENT, + getTextColor(context,message), + attachment + ) + onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } + } } } + // IMAGE / VIDEO message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { - /* - * Images / Video attachment - */ - if (contactIsTrusted || message.isOutgoing) { + if (mediaDownloaded || mediaInProgress || message.isOutgoing) { // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // bind after add view because views are inflated and calculated during bind binding.albumThumbnailView.root.bind( @@ -207,13 +220,22 @@ class VisibleMessageContentView : ConstraintLayout { } else { hideBody = true binding.albumThumbnailView.root.clearViews() - binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } + val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment + firstAttachment?.let { attachment -> + binding.pendingAttachmentView.root.bind( + PendingAttachmentView.AttachmentType.IMAGE, + getTextColor(context,message), + attachment + ) + onContentClick.add { + binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) + } + } } } message.isOpenGroupInvitation -> { hideBody = true - binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) + binding.openGroupInvitationView.root.bind(message, getTextColor(context, message)) onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() } } } @@ -250,7 +272,7 @@ class VisibleMessageContentView : ConstraintLayout { fun recycle() { arrayOf( binding.deletedMessageView.root, - binding.untrustedView.root, + binding.pendingAttachmentView.root, binding.voiceMessageView.root, binding.openGroupInvitationView.root, binding.documentView.root, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9538148fd..352191339 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -267,7 +267,6 @@ class VisibleMessageView : LinearLayout { glide, thread, searchQuery, - message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false), onAttachmentNeedsDownload ) binding.messageContentView.root.delegate = delegate diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt index ee1c7257c..b2803b2c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt @@ -11,23 +11,31 @@ object MentionManagerUtilities { fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) { val result = mutableSetOf() val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return - if (recipient.address.isClosedGroup) { - val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() } - result.addAll(members) - } else { - val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200)) - var record: MessageRecord? = reader.next - while (record != null) { - result.add(record.individualRecipient.address.serialize()) - try { - record = reader.next - } catch (exception: Exception) { - record = null - } + val storage = DatabaseComponent.get(context).storage() + when { + recipient.address.isLegacyClosedGroup -> { + val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() } + result.addAll(members) + } + recipient.address.isClosedGroup -> { + val members = storage.getMembers(recipient.address.serialize()) + result.addAll(members.map { it.sessionId }) + } + recipient.address.isOpenGroup -> { + val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase() + val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200)) + var record: MessageRecord? = reader.next + while (record != null) { + result.add(record.individualRecipient.address.serialize()) + try { + record = reader.next + } catch (exception: Exception) { + record = null + } + } + reader.close() + result.add(TextSecurePreferences.getLocalNumber(context)!!) } - reader.close() - result.add(TextSecurePreferences.getLocalNumber(context)!!) } MentionsManager.userPublicKeyCache[threadID] = result } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 45172e2f6..cd098777d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -966,10 +966,6 @@ public class AttachmentDatabase extends Database { @SuppressLint("NewApi") private ThumbnailData generateVideoThumbnail(AttachmentId attachmentId) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Video thumbnails not supported..."); - return null; - } DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt index 19a511bfd..43a3e115f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -4,6 +4,9 @@ import android.content.Context import androidx.core.content.contentValuesOf import androidx.core.database.getBlobOrNull import androidx.core.database.getLongOrNull +import androidx.sqlite.db.transaction +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { @@ -20,6 +23,11 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co "CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));" private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" + private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?" + + val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name + val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name + val MEMBER_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name } fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) { @@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) } + fun deleteGroupConfigs(closedGroupId: SessionId) { + val db = writableDatabase + db.transaction { + val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT) + db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE, + arrayOf(variants, closedGroupId.hexString()) + ) + } + } + + fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) { + val db = writableDatabase + db.transaction { + val keyContent = contentValuesOf( + VARIANT to KEYS_VARIANT, + PUBKEY to publicKey, + DATA to keysConfig, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, keyContent, VARIANT_AND_PUBKEY_WHERE, + arrayOf(KEYS_VARIANT, publicKey) + ) + val infoContent = contentValuesOf( + VARIANT to INFO_VARIANT, + PUBKEY to publicKey, + DATA to infoConfig, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, infoContent, VARIANT_AND_PUBKEY_WHERE, + arrayOf(INFO_VARIANT, publicKey) + ) + val memberContent = contentValuesOf( + VARIANT to MEMBER_VARIANT, + PUBKEY to publicKey, + DATA to memberConfig, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, memberContent, VARIANT_AND_PUBKEY_WHERE, + arrayOf(MEMBER_VARIANT, publicKey) + ) + } + } + fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? { val db = readableDatabase val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java index 1b273de92..134ea6e45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -44,7 +44,8 @@ public class MediaDatabase extends Database { + MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " - + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.LINK_PREVIEWS + " " + "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID @@ -52,7 +53,8 @@ public class MediaDatabase extends Database { + " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND " + AttachmentDatabase.DATA + " IS NOT NULL AND " + AttachmentDatabase.QUOTE + " = 0 AND " - + AttachmentDatabase.STICKER_PACK_ID + " IS NULL " + + AttachmentDatabase.STICKER_PACK_ID + " IS NULL AND " + + MmsDatabase.LINK_PREVIEWS + " IS NULL " + "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC"; private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 111b6d536..8555b248d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -826,23 +826,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - private fun deleteQuotedFromMessages(toDeleteRecords: List) { - if (toDeleteRecords.isEmpty()) return - val queryBuilder = StringBuilder() - for (i in toDeleteRecords.indices) { - queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId()) - if (i + 1 < toDeleteRecords.size) { - queryBuilder.append(" OR ") - } - } - val query = queryBuilder.toString() - val db = databaseHelper.writableDatabase - val values = ContentValues(2) - values.put(QUOTE_MISSING, 1) - values.put(QUOTE_AUTHOR, "") - db!!.update(TABLE_NAME, values, query, null) - } - /** * Delete all the messages in single queries where possible * @param messageIds a String array representation of regularly Long types representing message IDs @@ -926,6 +909,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa deleteThreads(setOf(threadId)) } + fun deleteMediaFor(threadId: Long, fromUser: String? = null) { + val db = databaseHelper.writableDatabase + val whereString = + if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL" + else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL" + val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser) + var cursor: Cursor? = null + try { + cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null) + val toDeleteStringMessageIds = mutableListOf() + while (cursor.moveToNext()) { + toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string + } + // TODO: this can probably be optimized out, + // currently attachmentDB uses MmsID not threadID which makes it difficult to delete + // and clean up on threadID alone + toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> + deleteMessages(sublist.toTypedArray()) + } + } finally { + cursor?.close() + } + val threadDb = get(context).threadDatabase() + threadDb.update(threadId, false, false) + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + } + + fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation + val db = databaseHelper.writableDatabase + var cursor: Cursor? = null + val whereString = "$THREAD_ID = ? AND $ADDRESS = ?" + try { + cursor = + db!!.query(TABLE_NAME, arrayOf(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null) + val toDeleteStringMessageIds = mutableListOf() + while (cursor.moveToNext()) { + toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string + } + // TODO: this can probably be optimized out, + // currently attachmentDB uses MmsID not threadID which makes it difficult to delete + // and clean up on threadID alone + toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> + deleteMessages(sublist.toTypedArray()) + } + } finally { + cursor?.close() + } + val threadDb = get(context).threadDatabase() + threadDb.update(threadId, false, true) + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + } + private fun getSerializedSharedContacts( insertedAttachmentIds: Map, contacts: List @@ -1069,7 +1108,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return false } - /*package*/ private fun deleteThreads(threadIds: Set) { val db = databaseHelper.writableDatabase val where = StringBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index dde5847ff..76b8cc1c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -64,13 +64,14 @@ public class RecipientDatabase extends Database { private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none private static final String WRAPPER_HASH = "wrapper_hash"; private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests"; + private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference private static final String[] RECIPIENT_PROJECTION = new String[] { BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, - UNIDENTIFIED_ACCESS_MODE, - FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS + UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, + BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD, }; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -109,6 +110,17 @@ public class RecipientDatabase extends Database { "ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;"; } + public static String getCreateAutoDownloadCommand() { + return "ALTER TABLE "+ TABLE_NAME + " " + + "ADD COLUMN " + AUTO_DOWNLOAD + " INTEGER DEFAULT -1;"; + } + + public static String getUpdateAutoDownloadValuesCommand() { + return "UPDATE "+TABLE_NAME+" SET "+AUTO_DOWNLOAD+" = 1 "+ + "WHERE "+ADDRESS+" IN (SELECT "+SessionContactDatabase.sessionContactTable+"."+SessionContactDatabase.sessionID+" "+ + "FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))"; + } + public static String getCreateApprovedCommand() { return "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;"; @@ -178,30 +190,31 @@ public class RecipientDatabase extends Database { } Optional getRecipientSettings(@NonNull Cursor cursor) { - boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1; - boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; - boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; - String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); - String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); - int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); - int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); - long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); - int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE)); - String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR)); - int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID)); - int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES)); - int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED)); - String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY)); - String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)); - String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI)); - String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL)); - String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI)); - String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); - String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR)); - boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; - String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); - int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); - boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; + boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1; + boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; + boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; + String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); + String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); + int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); + int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); + long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); + int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE)); + boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1; + String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR)); + int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID)); + int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES)); + int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED)); + String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY)); + String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)); + String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI)); + String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL)); + String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI)); + String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); + String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR)); + boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; + String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); + int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); + boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH)); boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1; @@ -225,7 +238,7 @@ public class RecipientDatabase extends Database { } return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, - notifyType, + notifyType, autoDownloadAttachments, Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(callVibrateState), Util.uri(messageRingtone), Util.uri(callRingtone), @@ -238,6 +251,22 @@ public class RecipientDatabase extends Database { forceSmsSelection, wrapperHash, blocksCommunityMessageRequests)); } + public boolean isAutoDownloadFlagSet(Recipient recipient) { + SQLiteDatabase db = getReadableDatabase(); + Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null); + boolean flagUnset = false; + try { + if (cursor.moveToFirst()) { + // flag isn't set if it is -1 + flagUnset = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == -1; + } + } finally { + cursor.close(); + } + // negate result (is flag set) + return !flagUnset; + } + public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { ContentValues values = new ContentValues(); values.put(COLOR, color.serialize()); @@ -313,6 +342,21 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } + public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) { + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + ContentValues values = new ContentValues(); + values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0); + db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()}); + recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + notifyRecipientListeners(); + } + public void setMuted(@NonNull Recipient recipient, long until) { ContentValues values = new ContentValues(); values.put(MUTE_UNTIL, until); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 49a633936..1e985d141 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -3,17 +3,16 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor -import androidx.core.database.getStringOrNull import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.utilities.SessionId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { companion object { - private const val sessionContactTable = "session_contact_database" + const val sessionContactTable = "session_contact_database" const val sessionID = "session_id" const val name = "name" const val nickname = "nickname" @@ -74,23 +73,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it)) } contentValues.put(threadID, contact.threadID) - contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0) database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID )) notifyConversationListListeners() } fun contactFromCursor(cursor: Cursor): Contact { - val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) + val sessionID = cursor.getString(sessionID) val contact = Contact(sessionID) - contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) - contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) - contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) - contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName)) - cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let { + contact.name = cursor.getStringOrNull(name) + contact.nickname = cursor.getStringOrNull(nickname) + contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL) + contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName) + cursor.getStringOrNull(profilePictureEncryptionKey)?.let { contact.profilePictureEncryptionKey = Base64.decode(it) } - contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID)) - contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0 + contact.threadID = cursor.getLong(threadID) return contact } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index 591755b88..c400a7e71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -7,6 +7,7 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.InviteContactsJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob @@ -73,6 +74,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa return result.firstOrNull { job -> job.attachmentID == attachmentID } } + fun getGroupInviteJob(groupSessionId: String, memberSessionId: String): InviteContactsJob? { + val database = databaseHelper.readableDatabase + return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(InviteContactsJob.KEY)) { cursor -> + jobFromCursor(cursor) as? InviteContactsJob + }.firstOrNull { it != null && it.groupSessionId == groupSessionId && it.memberSessionIds.contains(memberSessionId) } + } + fun getMessageSendJob(messageSendJobID: String): MessageSendJob? { val database = databaseHelper.readableDatabase return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 4ef576f40..e48f27d49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -685,11 +685,16 @@ public class SmsDatabase extends MessagingDatabase { } } - /*package */void deleteThread(long threadId) { + void deleteThread(long threadId) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); } + void deleteMessagesFrom(long threadId, String fromUser) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser}); + } + /*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); String where = THREAD_ID + " = ? AND (CASE " + TYPE; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 481a4fa06..140367e24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,18 +2,26 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri -import network.loki.messenger.libsession_util.ConfigBase +import com.google.protobuf.ByteString +import network.loki.messenger.libsession_util.Config import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.GroupInfoConfig +import network.loki.messenger.libsession_util.GroupKeysConfig +import network.loki.messenger.libsession_util.GroupMembersConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupDisplayInfo import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.KeyPair import network.loki.messenger.libsession_util.util.UserPic +import nl.komponents.kovenant.functional.bind import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping @@ -22,7 +30,9 @@ import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.ConfigurationSyncJob.Companion.messageInformation import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.InviteContactsJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveJob @@ -31,6 +41,7 @@ import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage +import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage import org.session.libsession.messaging.messages.signal.IncomingGroupMessage @@ -46,18 +57,21 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.signingKeyCallback +import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupRecord @@ -71,17 +85,26 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.protos.SignalServiceProtos.DataMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.SessionId import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.dependencies.PollerFactory +import org.thoughtcrime.securesms.dependencies.Toaster import org.thoughtcrime.securesms.groups.ClosedGroupManager import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager @@ -90,8 +113,15 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.security.MessageDigest import network.loki.messenger.libsession_util.util.Contact as LibSessionContact +import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember -open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol, +open class Storage( + context: Context, + helper: SQLCipherOpenHelper, + private val configFactory: ConfigFactory, + private val pollerFactory: PollerFactory, + private val toaster: Toaster, +) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { override fun threadCreated(address: Address, threadId: Long) { @@ -101,20 +131,31 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val volatile = configFactory.convoVolatile ?: return if (address.isGroup) { val groups = configFactory.userGroups ?: return - if (address.isClosedGroup) { - val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) - val closedGroup = getGroup(address.toGroupString()) - if (closedGroup != null && closedGroup.isActive) { - val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId) - groups.set(legacyGroup) - val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy( - lastRead = SnodeAPI.nowWithOffset, - ) - volatile.set(newVolatileParams) + when { + address.isLegacyClosedGroup -> { + val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) + val closedGroup = getGroup(address.toGroupString()) + if (closedGroup != null && closedGroup.isActive) { + val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId) + groups.set(legacyGroup) + val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy( + lastRead = SnodeAPI.nowWithOffset, + ) + volatile.set(newVolatileParams) + } + } + address.isClosedGroup -> { + val sessionId = address.serialize() + groups.getClosedGroup(sessionId) ?: return Log.d("Closed group doesn't exist locally", NullPointerException()) + val conversation = Conversation.ClosedGroup( + sessionId, 0, false + ) + volatile.set(conversation) + } + address.isOpenGroup -> { + // these should be added on the group join / group info fetch + Log.w("Loki", "Thread created called for open group address, not adding any extra information") } - } else if (address.isOpenGroup) { - // these should be added on the group join / group info fetch - Log.w("Loki", "Thread created called for open group address, not adding any extra information") } } else if (address.isContact) { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config @@ -123,11 +164,11 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co if (getUserPublicKey() != address.serialize()) { val contacts = configFactory.contacts ?: return contacts.upsertContact(address.serialize()) { - priority = ConfigBase.PRIORITY_VISIBLE + priority = PRIORITY_VISIBLE } } else { val userProfile = configFactory.user ?: return - userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE) + userProfile.setNtsPriority(PRIORITY_VISIBLE) DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true) } val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize()) @@ -139,13 +180,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val volatile = configFactory.convoVolatile ?: return if (address.isGroup) { val groups = configFactory.userGroups ?: return - if (address.isClosedGroup) { + if (address.isLegacyClosedGroup) { val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) volatile.eraseLegacyClosedGroup(sessionId) groups.eraseLegacyGroup(sessionId) } else if (address.isOpenGroup) { // these should be removed in the group leave / handling new configs Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") + } else if (address.isClosedGroup) { + Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere") } } else { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config @@ -246,7 +289,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co configFactory.convoVolatile?.let { config -> val convo = when { // recipient closed group - recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + recipient.isLegacyClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + recipient.isClosedGroupRecipient -> config.getOrConstructClosedGroup(recipient.address.serialize()) // recipient is open group recipient.isOpenGroupRecipient -> { val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return @@ -294,6 +338,9 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co ?.let { SodiumUtilities.sessionId(getUserPublicKey()!!, message.sender!!, it) } ?: false val group: Optional = when { openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) + groupPublicKey != null && groupPublicKey.startsWith(IdPrefix.GROUP.value) -> { + Optional.of(SignalServiceGroup(Hex.fromStringCondensed(groupPublicKey), SignalServiceGroup.GroupType.SIGNAL)) + } groupPublicKey != null -> { val doubleEncoded = GroupUtil.doubleEncodeGroupID(groupPublicKey) Optional.of(SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(doubleEncoded), SignalServiceGroup.GroupType.SIGNAL)) @@ -306,7 +353,14 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val targetAddress = if ((isUserSender || isUserBlindedSender) && !message.syncTarget.isNullOrEmpty()) { fromSerialized(message.syncTarget!!) } else if (group.isPresent) { - fromSerialized(GroupUtil.getEncodedId(group.get())) + val idHex = group.get().groupId.toHexString() + if (idHex.startsWith(IdPrefix.GROUP.value)) { + fromSerialized(idHex) + } else { + fromSerialized(GroupUtil.getEncodedId(group.get())) + } + } else if (message.recipient?.startsWith(IdPrefix.GROUP.value) == true) { + fromSerialized(message.recipient!!) } else { senderAddress } @@ -423,7 +477,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) } - override fun notifyConfigUpdates(forConfigObject: ConfigBase) { + override fun notifyConfigUpdates(forConfigObject: Config) { notifyUpdates(forConfigObject) } @@ -439,12 +493,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return configFactory.user?.getCommunityMessageRequests() == true } - fun notifyUpdates(forConfigObject: ConfigBase) { + private fun notifyUpdates(forConfigObject: Config) { when (forConfigObject) { is UserProfile -> updateUser(forConfigObject) is Contacts -> updateContacts(forConfigObject) is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject) is UserGroupsConfig -> updateUserGroups(forConfigObject) + is GroupInfoConfig -> updateGroupInfo(forConfigObject) + is GroupKeysConfig -> updateGroupKeys(forConfigObject) + is GroupMembersConfig -> updateGroupMembers(forConfigObject) } } @@ -481,6 +538,22 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } + private fun updateGroupInfo(groupInfoConfig: GroupInfoConfig) { + val threadId = getOrCreateThreadIdFor(Address.fromSerialized(groupInfoConfig.id().hexString())) + val recipient = getRecipientForThread(threadId) ?: return + val db = DatabaseComponent.get(context).recipientDatabase() + db.setProfileName(recipient, groupInfoConfig.getName()) + // TODO: handle deleted group, handle delete attachment / message before a certain time + } + + private fun updateGroupKeys(groupKeys: GroupKeysConfig) { + // TODO: update something here? + } + + private fun updateGroupMembers(groupMembers: GroupMembersConfig) { + // TODO: maybe clear out some contacts or something? + } + private fun updateContacts(contacts: Contacts) { val extracted = contacts.all().toList() addLibSessionContacts(extracted) @@ -510,6 +583,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false) is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) + is Conversation.ClosedGroup -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) // New groups will be managed bia libsession } if (threadId != null) { if (conversation.lastRead > getLastSeen(threadId)) { @@ -537,9 +611,9 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } } val existingJoinUrls = existingCommunities.values.map { it.joinURL } - val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup } - val lgcIds = lgc.map { it.sessionId } - val toDeleteClosedGroups = existingClosedGroups.filter { group -> + val existingLegacyClosedGroups = getAllGroups(includeInactive = true).filter { it.isLegacyClosedGroup } + val lgcIds = lgc.map { it.sessionId.hexString() } + val toDeleteClosedGroups = existingLegacyClosedGroups.filter { group -> GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds } @@ -571,8 +645,21 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } } + val newClosedGroups = userGroups.allClosedGroupInfo() + for (closedGroup in newClosedGroups) { + val recipient = Recipient.from(context, Address.fromSerialized(closedGroup.groupSessionId.hexString()), false) + setRecipientApprovedMe(recipient, true) + setRecipientApproved(recipient, !closedGroup.invited) + val threadId = getOrCreateThreadIdFor(recipient.address) + setPinned(threadId, closedGroup.priority == PRIORITY_PINNED) + if (!closedGroup.invited) { + pollerFactory.pollerFor(closedGroup.groupSessionId)?.start() + } + } + // TODO: add in removing legacy closed groups via config update + for (group in lgc) { - val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } + val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId.hexString() } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } if (existingGroup != null) { if (group.priority == PRIORITY_HIDDEN && existingThread != null) { @@ -586,28 +673,28 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } else { val members = group.members.keys.map { Address.fromSerialized(it) } val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) } - val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) + val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId.hexString()) val title = group.name val formationTimestamp = (group.joinedAt * 1000L) createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) setProfileSharing(Address.fromSerialized(groupId), true) // Add the group to the user's set of public keys to poll for - addClosedGroupPublicKey(group.sessionId) + addClosedGroupPublicKey(group.sessionId.hexString()) // Store the encryption key pair val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) - addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) + addClosedGroupEncryptionKeyPair(keyPair, group.sessionId.hexString(), SnodeAPI.nowWithOffset) // Set expiration timer val expireTimer = group.disappearingTimer setExpirationTimer(groupId, expireTimer.toInt()) // Notify the PN server - PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) + PushRegistryV1.subscribeGroup(group.sessionId.hexString(), publicKey = localUserPublicKey) // Notify the user val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) threadDb.setDate(threadID, formationTimestamp) insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) // Don't create config group here, it's from a config update // Start polling - ClosedGroupPollerV2.shared.startPolling(group.sessionId) + LegacyClosedGroupPollerV2.shared.startPolling(group.sessionId.hexString()) } } } @@ -847,6 +934,127 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) } + override fun createNewGroup(groupName: String, groupDescription: String, members: Set): Optional { + val userGroups = configFactory.userGroups ?: return Optional.absent() + val convoVolatile = configFactory.convoVolatile ?: return Optional.absent() + val ourSessionId = getUserPublicKey() ?: return Optional.absent() + + val groupCreationTimestamp = SnodeAPI.nowWithOffset + + val group = userGroups.createGroup() + val adminKey = group.adminKey + userGroups.set(group) + val groupInfo = configFactory.getGroupInfoConfig(group.groupSessionId) ?: return Optional.absent() + val groupMembers = configFactory.getGroupMemberConfig(group.groupSessionId) ?: return Optional.absent() + + with (groupInfo) { + setName(groupName) + setDescription(groupDescription) + } + + groupMembers.set( + LibSessionGroupMember(ourSessionId, getUserProfile().displayName, admin = true) + ) + + members.forEach { groupMembers.set(LibSessionGroupMember(it.sessionID, it.name, invitePending = true)) } + + val groupKeys = configFactory.constructGroupKeysConfig(group.groupSessionId, + info = groupInfo, + members = groupMembers) ?: return Optional.absent() + + val newGroupRecipient = group.groupSessionId.hexString() + val configTtl = 14 * 24 * 60 * 60 * 1000L + // Test the sending + val keyPush = groupKeys.pendingConfig() ?: return Optional.absent() + + val keysSnodeMessage = SnodeMessage( + newGroupRecipient, + Base64.encodeBytes(keyPush), + configTtl, + groupCreationTimestamp + ) + val keysBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo( + groupKeys.namespace(), + keysSnodeMessage, + adminKey + ) + + val (infoPush, infoSeqNo) = groupInfo.push() + val infoSnodeMessage = SnodeMessage( + newGroupRecipient, + Base64.encodeBytes(infoPush), + configTtl, + groupCreationTimestamp + ) + val infoBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo( + groupInfo.namespace(), + infoSnodeMessage, + adminKey + ) + + val (memberPush, memberSeqNo) = groupMembers.push() + val memberSnodeMessage = SnodeMessage( + newGroupRecipient, + Base64.encodeBytes(memberPush), + configTtl, + groupCreationTimestamp + ) + val memberBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo( + groupMembers.namespace(), + memberSnodeMessage, + adminKey + ) + + try { + val snode = SnodeAPI.getSingleTargetSnode(newGroupRecipient).get() + val response = SnodeAPI.getRawBatchResponse( + snode, + newGroupRecipient, + listOf(keysBatchInfo, infoBatchInfo, memberBatchInfo), + true + ).get() + + @Suppress("UNCHECKED_CAST") + val responseList = (response["results"] as List) + + val keyResponse = responseList[0] + val keyHash = (keyResponse["body"] as Map)["hash"] as String + val keyTimestamp = (keyResponse["body"] as Map)["t"] as Long + val infoResponse = responseList[1] + val infoHash = (infoResponse["body"] as Map)["hash"] as String + val memberResponse = responseList[2] + val memberHash = (memberResponse["body"] as Map)["hash"] as String + // TODO: check response success + groupKeys.loadKey(keyPush, keyHash, keyTimestamp, groupInfo, groupMembers) + groupInfo.confirmPushed(infoSeqNo, infoHash) + groupMembers.confirmPushed(memberSeqNo, memberHash) + + configFactory.saveGroupConfigs(groupKeys, groupInfo, groupMembers) // now check poller to be all + convoVolatile.set(Conversation.ClosedGroup(newGroupRecipient, groupCreationTimestamp, false)) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + Log.d("Group Config", "Saved group config for $newGroupRecipient") + groupKeys.free() + groupInfo.free() + groupMembers.free() + val groupRecipient = Recipient.from(context, fromSerialized(newGroupRecipient), false) + setRecipientApprovedMe(groupRecipient, true) + setRecipientApproved(groupRecipient, true) + pollerFactory.updatePollers() + + val memberArray = members.map(Contact::sessionID).toTypedArray() + val job = InviteContactsJob(group.groupSessionId.hexString(), memberArray) + JobQueue.shared.add(job) + return Optional.of(groupRecipient) + } catch (e: Exception) { + Log.e("Group Config", e) + Log.e("Group Config", "Deleting group from our group") + // delete the group from user groups + userGroups.erase(group) + } + + return Optional.absent() + } + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) { val volatiles = configFactory.convoVolatile ?: return val userGroups = configFactory.userGroups ?: return @@ -854,10 +1062,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co groupVolatileConfig.lastRead = formationTimestamp volatiles.set(groupVolatileConfig) val groupInfo = GroupInfo.LegacyGroupInfo( - sessionId = groupPublicKey, + sessionId = SessionId.from(groupPublicKey), name = name, members = members, - priority = ConfigBase.PRIORITY_VISIBLE, + priority = PRIORITY_VISIBLE, encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = encryptionKeyPair.privateKey.serialize(), disappearingTimer = 0L, @@ -893,7 +1101,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co members = membersMap, encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = latestKeyPair.privateKey.serialize(), - priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE, disappearingTimer = recipientSettings.expireMessages.toLong(), joinedAt = (existingGroup.formationTimestamp / 1000L) ) @@ -928,7 +1136,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() - val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) + val infoMessage = IncomingGroupMessage(m, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() smsDB.insertMessageInbox(infoMessage, true) } @@ -946,10 +1154,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co mmsDB.markAsSent(infoMessageID, true) } - override fun isClosedGroup(publicKey: String): Boolean { - val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey) - val address = fromSerialized(publicKey) - return address.isClosedGroup || isClosedGroup + override fun isLegacyClosedGroup(publicKey: String): Boolean { + return DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey) } override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList { @@ -1013,6 +1219,554 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } } + override fun getMembers(groupPublicKey: String): List = + configFactory.getGroupMemberConfig(SessionId.from(groupPublicKey))?.use { it.all() }?.toList() ?: emptyList() + + override fun respondToClosedGroupInvitation(groupRecipient: Recipient, approved: Boolean) { + val groups = configFactory.userGroups ?: return + val groupSessionId = SessionId.from(groupRecipient.address.serialize()) + if (!approved) { + groups.eraseClosedGroup(groupSessionId.hexString()) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + return + } else { + val closedGroupInfo = groups.getClosedGroup(groupSessionId.hexString())?.copy( + invited = false + ) ?: return + groups.set(closedGroupInfo) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + pollerFactory.pollerFor(groupSessionId)?.start() + val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder() + .setIsApproved(true) + val responseData = GroupUpdateMessage.newBuilder() + .setInviteResponse(inviteResponse) + val responseMessage = GroupUpdated(responseData.build()) + // this will fail the first couple of times :) + MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString())) + } + } + + override fun addClosedGroupInvite( + groupId: SessionId, + name: String, + authData: ByteArray, + invitingAdmin: SessionId + ) { + val recipient = Recipient.from(context, fromSerialized(groupId.hexString()), false) + val profileManager = SSKEnvironment.shared.profileManager + val groups = configFactory.userGroups ?: return + val shouldAutoApprove = false //TESTING// getRecipientApproved(fromSerialized(invitingAdmin.hexString())) + val closedGroupInfo = GroupInfo.ClosedGroupInfo( + groupId, + byteArrayOf(), + authData, + PRIORITY_VISIBLE, + !shouldAutoApprove, + ) + groups.set(closedGroupInfo) + configFactory.persist(groups, SnodeAPI.nowWithOffset) + profileManager.setName(context, recipient, name) + getOrCreateThreadIdFor(recipient.address) + setRecipientApprovedMe(recipient, true) + setRecipientApproved(recipient, shouldAutoApprove) + if (shouldAutoApprove) { + pollerFactory.pollerFor(groupId)?.start() + val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder() + .setIsApproved(true) + val responseData = GroupUpdateMessage.newBuilder() + .setInviteResponse(inviteResponse) + val responseMessage = GroupUpdated(responseData.build()) + // this will fail the first couple of times :) + MessageSender.send(responseMessage, fromSerialized(groupId.hexString())) + } + } + + override fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: SessionId) { + // don't try to process invitee acceptance if we aren't admin + if (configFactory.userGroups?.getClosedGroup(closedGroup.hexString())?.hasAdminKey() != true) return + + configFactory.getGroupMemberConfig(closedGroup)?.use { groupMembers -> + val member = groupMembers.get(invitee) ?: run { + Log.e("ClosedGroup", "User wasn't in the group membership to add!") + return + } + if (!member.invitePending) return groupMembers.close() + if (approved) { + groupMembers.set(member.copy(invitePending = false)) + } else { + groupMembers.erase(member) + } + configFactory.persistGroupConfigDump(groupMembers, closedGroup, SnodeAPI.nowWithOffset) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.ClosedGroup(closedGroup.hexString())) + } + } + + override fun getLibSessionClosedGroup(groupSessionId: String): GroupInfo.ClosedGroupInfo? { + return configFactory.userGroups?.getClosedGroup(groupSessionId) + } + + override fun getClosedGroupDisplayInfo(groupSessionId: String): GroupDisplayInfo? { + val infoConfig = configFactory.getGroupInfoConfig(SessionId.from(groupSessionId)) ?: return null + val isAdmin = configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() ?: return null + + return infoConfig.use { info -> + GroupDisplayInfo( + id = info.id(), + name = info.getName(), + profilePic = info.getProfilePic(), + expiryTimer = info.getExpiryTimer(), + destroyed = false, + created = info.getCreated(), + description = info.getDescription(), + isUserAdmin = isAdmin + ) + } + } + + override fun inviteClosedGroupMembers(groupSessionId: String, invitees: List) { + // don't try to process invitee acceptance if we aren't admin + if (configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() != true) return + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + val sessionId = SessionId.from(groupSessionId) + val membersConfig = configFactory.getGroupMemberConfig(sessionId) ?: return + val infoConfig = configFactory.getGroupInfoConfig(sessionId) ?: return + + // Filter out people who aren't already invited + val filteredMembers = invitees.filter { + membersConfig.get(it) == null + } + // Create each member's contact info if we have it + filteredMembers.forEach { memberSessionId -> + val contact = getContactWithSessionID(memberSessionId) + val name = contact?.name + val url = contact?.profilePictureURL + val key = contact?.profilePictureEncryptionKey + val userPic = if (url != null && key != null) { + UserPic(url, key) + } else UserPic.DEFAULT + val member = membersConfig.getOrConstruct(memberSessionId).copy( + name = name, + profilePicture = userPic, + invitePending = true, + ) + membersConfig.set(member) + } + + // re-key for new members + val keysConfig = configFactory.getGroupKeysConfig( + sessionId, + info = infoConfig, + members = membersConfig, + free = false + ) ?: return + + keysConfig.rekey(infoConfig, membersConfig) + + // build unrevocation, in case of re-adding members + val unrevocation = SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupSessionId, + adminKey, + filteredMembers.map { keysConfig.getSubAccountToken(SessionId.from(it)) }.toTypedArray() + ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") + + // Build and store the key update in group swarm + val toDelete = mutableListOf() + + val signCallback = signingKeyCallback(adminKey) + + val keyMessage = keysConfig.messageInformation(groupSessionId, adminKey) + val infoMessage = infoConfig.messageInformation(toDelete, groupSessionId, adminKey) + val membersMessage = membersConfig.messageInformation(toDelete, groupSessionId, adminKey) + + val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo( + groupSessionId, + toDelete, + signCallback + ) + + val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch) + + val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + groupSessionId, + stores + unrevocation + delete, + sequence = true + ) + } + + try { + val rawResponse = response.get() + val results = (rawResponse["results"] as ArrayList).first() as Map + if (results["code"] as Int != 200) { + throw Exception("Response wasn't successful for unrevoke and key update: ${results["body"] as? String}") + } + + configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig) + + val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray()) + JobQueue.shared.add(job) + + val timestamp = SnodeAPI.nowWithOffset + val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.ADDED.name}$timestamp" + val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) + val updatedMessage = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(filteredMembers) + .setType(GroupUpdateMemberChangeMessage.Type.ADDED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { this.sentTimestamp = timestamp } + MessageSender.send(updatedMessage, fromSerialized(groupSessionId)) + insertGroupInfoChange(updatedMessage, sessionId) + infoConfig.free() + membersConfig.free() + keysConfig.free() + } catch (e: Exception) { + Log.e("ClosedGroup", "Failed to store new key", e) + infoConfig.free() + membersConfig.free() + keysConfig.free() + // toaster toast here + return + } + + } + + override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId) { + val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset + val senderPublicKey = message.sender + val userPublicKey = getUserPublicKey()!! + val updateData = UpdateMessageData.buildGroupUpdate(message)?.toJSON() ?: return + + if (senderPublicKey == null || senderPublicKey == userPublicKey) { + val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString()), false) + val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, closedGroup.hexString(), null, sentTimestamp, 0, true, null, listOf(), listOf()) + val mmsDB = DatabaseComponent.get(context).mmsDatabase() + val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() + if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return + val threadDb = DatabaseComponent.get(context).threadDatabase() + val threadID = threadDb.getThreadIdIfExistsFor(recipient) + val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + mmsDB.markAsSent(infoMessageID, true) + } else { + val group = SignalServiceGroup(Hex.fromStringCondensed(closedGroup.hexString()), SignalServiceGroup.GroupType.SIGNAL) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false) + val infoMessage = IncomingGroupMessage(m, updateData, true) + val smsDB = DatabaseComponent.get(context).smsDatabase() + smsDB.insertMessageInbox(infoMessage, true) + } + } + + override fun promoteMember(groupSessionId: String, promotions: Array) { + val closedGroupId = SessionId.from(groupSessionId) + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + if (adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return + val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return + val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return + + promotions.forEach { sessionId -> + val promoted = members.get(sessionId)?.copy( + promotionPending = true, + ) ?: return@forEach + members.set(promoted) + + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setPromoteMessage( + DataMessage.GroupUpdatePromoteMessage.newBuilder() + .setGroupIdentitySeed(ByteString.copyFrom(adminKey)) + ) + .build() + ) + MessageSender.send(message, fromSerialized(sessionId)) + } + configFactory.saveGroupConfigs(keys, info, members) + info.free() + members.free() + keys.free() + val groupDestination = Destination.ClosedGroup(groupSessionId) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) + val timestamp = SnodeAPI.nowWithOffset + val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.PROMOTED.name}$timestamp" + val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(promotions.toList()) + .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { + sentTimestamp = timestamp + } + MessageSender.send(message, fromSerialized(groupSessionId)) + insertGroupInfoChange(message, closedGroupId) + } + + override fun removeMember(groupSessionId: String, removedMembers: Array, fromDelete: Boolean) { + val closedGroupId = SessionId.from(groupSessionId) + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + if (adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return + val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return + val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return + + removedMembers.forEach { sessionId -> + members.erase(sessionId) + } + + // Re-key for removed members + keys.rekey(info, members) + + val revocation = SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + groupSessionId, + adminKey, + removedMembers.map { keys.getSubAccountToken(SessionId.from(it)) }.toTypedArray() + ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") + + keys.rekey(info, members) + + val toDelete = mutableListOf() + + val keyMessage = keys.messageInformation(groupSessionId, adminKey) + val infoMessage = info.messageInformation(toDelete, groupSessionId, adminKey) + val membersMessage = members.messageInformation(toDelete, groupSessionId, adminKey) + + val signCallback = signingKeyCallback(adminKey) + + val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo( + groupSessionId, + toDelete, + signCallback + ) + + val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch) + + val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + groupSessionId, + stores + revocation + delete, + sequence = true + ) + } + + try { + // handle new key update and revocations response + val rawResponse = response.get() + val results = (rawResponse["results"] as ArrayList).first() as Map + if (results["code"] as Int != 200) { + throw Exception("Response wasn't successful for revoke and key update: ${results["body"] as? String}") + } + + configFactory.saveGroupConfigs(keys, info, members) + info.free() + members.free() + keys.free() + + val timestamp = SnodeAPI.nowWithOffset + val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp" + val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) + val updateMessage = GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.toList()) + .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + val message = GroupUpdated( + updateMessage + ).apply { sentTimestamp = timestamp } + val groupDestination = Destination.ClosedGroup(groupSessionId) + MessageSender.send(message, groupDestination, false) + insertGroupInfoChange(message, closedGroupId) + } catch (e: Exception) { + info.free() + members.free() + keys.free() + } + } + + override fun handlePromoted(keyPair: KeyPair) { + val closedGroupId = SessionId(IdPrefix.GROUP, keyPair.pubKey) + val ourSessionId = getUserPublicKey()!! + val userGroups = configFactory.userGroups ?: return + val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString()) + ?: return Log.w("ClosedGroup", "No closed group in user groups matching promoted message") + + val modified = closedGroup.copy(adminKey = keyPair.secretKey, authData = byteArrayOf()) + userGroups.set(modified) + configFactory.scheduleUpdate(Destination.from(fromSerialized(getUserPublicKey()!!))) + val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return + val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return + val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return + val ourMember = members.get(ourSessionId)?.copy( + admin = true, + promotionPending = false, + promotionFailed = false + ) ?: return Log.e("ClosedGroup", "We aren't a member in the closed group") + members.set(ourMember) + configFactory.saveGroupConfigs(keys, info, members) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.ClosedGroup(closedGroupId.hexString())) + info.free() + members.free() + keys.free() + } + + override fun handleMemberLeft(message: GroupUpdated, closedGroupId: SessionId) { + val userGroups = configFactory.userGroups ?: return + val closedGroupHexString = closedGroupId.hexString() + val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString()) ?: return + if (closedGroup.hasAdminKey()) { + // re-key and do a new config removing the previous member + val adminKey = closedGroup.adminKey + val signCallback = signingKeyCallback(adminKey) + val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return + val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return + val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return + members.erase(message.sender!!) + + keys.rekey(info, members) + + val toDelete = mutableListOf() + + val keyMessage = keys.messageInformation(closedGroupHexString, adminKey) + val infoMessage = info.messageInformation(toDelete, closedGroupHexString, adminKey) + val membersMessage = members.messageInformation(toDelete, closedGroupHexString, adminKey) + + val revocation = SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + closedGroupHexString, + adminKey, + arrayOf(message.sender!!.let { keys.getSubAccountToken(SessionId.from(it)) }) + ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") + + val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo( + closedGroupHexString, + toDelete, + signCallback + ) + + val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch) + + val response = SnodeAPI.getSingleTargetSnode(closedGroupHexString).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + closedGroupHexString, + stores + revocation + delete, + sequence = true + ) + } + + try { + val rawResponse = response.get() + val results = (rawResponse["results"] as ArrayList).first() as Map + if (results["code"] as Int != 200) { + throw Exception("Response wasn't successful for revoke and key update: ${results["body"] as? String}") + } + + configFactory.saveGroupConfigs(keys, info, members) + + val timestamp = SnodeAPI.nowWithOffset + val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp" + val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) + val updatedMessage = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(listOf(message.sender!!)) + .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { this.sentTimestamp = timestamp } + MessageSender.send(updatedMessage, fromSerialized(closedGroupHexString)) + insertGroupInfoChange(updatedMessage, closedGroupId) + info.free() + members.free() + keys.free() + } catch (e: Exception) { + Log.e("ClosedGroup", "Failed to store new key", e) + info.free() + members.free() + keys.free() + // toaster toast here + return + } + } + + insertGroupInfoChange(message, closedGroupId) + } + + override fun leaveGroup(groupSessionId: String) { + val closedGroupId = SessionId.from(groupSessionId) + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .build() + ) + try { + MessageSender.sendNonDurably(message, fromSerialized(groupSessionId), false).get() + pollerFactory.pollerFor(closedGroupId)?.stop() + // TODO: unsub from pushes + getThreadId(fromSerialized(groupSessionId))?.let { threadId -> + deleteConversation(threadId) + } + configFactory.removeGroup(closedGroupId) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } catch (e: Exception) { + Log.e("ClosedGroup", "Failed to send leave group message") + } + } + + override fun setName(groupSessionId: String, newName: String) { + val closedGroupId = SessionId.from(groupSessionId) + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + if (adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return + val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return + val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return + + info.setName(newName) + + configFactory.saveGroupConfigs(keys, info, members) + info.free() + members.free() + keys.free() + val groupDestination = Destination.ClosedGroup(groupSessionId) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) + val timestamp = SnodeAPI.nowWithOffset + val messageToSign = "INFO_CHANGE${GroupUpdateInfoChangeMessage.Type.NAME.name}$timestamp" + val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setInfoChangeMessage( + GroupUpdateInfoChangeMessage.newBuilder() + .setUpdatedName(newName) + .setType(GroupUpdateInfoChangeMessage.Type.NAME) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { + sentTimestamp = timestamp + } + MessageSender.send(message, fromSerialized(groupSessionId)) + insertGroupInfoChange(message, closedGroupId) + } + override fun setServerCapabilities(server: String, capabilities: List) { return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) } @@ -1074,10 +1828,14 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return if (!openGroupID.isNullOrEmpty()) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } - } else if (!groupPublicKey.isNullOrEmpty()) { + } else if (!groupPublicKey.isNullOrEmpty() && !groupPublicKey.startsWith(IdPrefix.GROUP.value)) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) if (createThread) database.getOrCreateThreadIdFor(recipient) else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } + } else if (!groupPublicKey.isNullOrEmpty()) { + val recipient = Recipient.from(context, fromSerialized(groupPublicKey), false) + if (createThread) database.getOrCreateThreadIdFor(recipient) + else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } else { val recipient = Recipient.from(context, fromSerialized(publicKey), false) if (createThread) database.getOrCreateThreadIdFor(recipient) @@ -1139,6 +1897,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return if (recipientSettings.isPresent) { recipientSettings.get() } else null } + override fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean { + return DatabaseComponent.get(context).recipientDatabase().isAutoDownloadFlagSet(recipient) + } + override fun addLibSessionContacts(contacts: List) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> @@ -1226,6 +1988,18 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } } + override fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean { + return recipient.autoDownloadAttachments + } + + override fun setAutoDownloadAttachments( + recipient: Recipient, + shouldAutoDownloadAttachments: Boolean + ) { + val recipientDb = DatabaseComponent.get(context).recipientDatabase() + recipientDb.setAutoDownloadAttachments(recipient, shouldAutoDownloadAttachments) + } + override fun setRecipientHash(recipient: Recipient, recipientHash: String?) { val recipientDb = DatabaseComponent.get(context).recipientDatabase() recipientDb.setRecipientHash(recipient, recipientHash) @@ -1257,27 +2031,36 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val threadRecipient = getRecipientForThread(threadID) ?: return if (threadRecipient.isLocalNumber) { val user = configFactory.user ?: return - user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE) + user.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) } else if (threadRecipient.isContactRecipient) { val contacts = configFactory.contacts ?: return contacts.upsertContact(threadRecipient.address.serialize()) { - priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE } } else if (threadRecipient.isGroupRecipient) { val groups = configFactory.userGroups ?: return - if (threadRecipient.isClosedGroupRecipient) { - val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize()) - val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy ( - priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE - ) - groups.set(newGroupInfo) - } else if (threadRecipient.isOpenGroupRecipient) { - val openGroup = getOpenGroup(threadID) ?: return - val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return - val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( - priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE - ) - groups.set(newGroupInfo) + when { + threadRecipient.isLegacyClosedGroupRecipient -> { + val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize()) + val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy ( + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } + threadRecipient.isClosedGroupRecipient -> { + val newGroupInfo = groups.getOrConstructClosedGroup(threadRecipient.address.serialize()).copy ( + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } + threadRecipient.isOpenGroupRecipient -> { + val openGroup = getOpenGroup(threadID) ?: return + val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return + val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } } } ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) @@ -1324,6 +2107,27 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } } + override fun clearMessages(threadID: Long, fromUser: Address?): Boolean { + val smsDb = DatabaseComponent.get(context).smsDatabase() + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + val threadDb = DatabaseComponent.get(context).threadDatabase() + if (fromUser == null) { + smsDb.deleteThread(threadID) + mmsDb.deleteThread(threadID) // threadDB update called from within + } else { + smsDb.deleteMessagesFrom(threadID, fromUser.serialize()) + mmsDb.deleteMessagesFrom(threadID, fromUser.serialize()) + threadDb.update(threadID, false, true) + } + return true + } + + override fun clearMedia(threadID: Long, fromUser: Address?): Boolean { + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + mmsDb.deleteMediaFor(threadID, fromUser?.serialize()) + return true + } + override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { return PartAuthority.getAttachmentDataUri(attachmentId) } @@ -1463,7 +2267,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } override fun getRecipientApproved(address: Address): Boolean { - return DatabaseComponent.get(context).recipientDatabase().getApproved(address) + return address.isClosedGroup || DatabaseComponent.get(context).recipientDatabase().getApproved(address) } override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { @@ -1535,8 +2339,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } getAllContacts().forEach { contact -> val sessionId = SessionId(contact.sessionID) - if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) { - val contactMapping = mapping.copy(sessionId = sessionId.hexString) + if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString(), blindedId, serverPublicKey)) { + val contactMapping = mapping.copy(sessionId = sessionId.hexString()) db.addBlindedIdMapping(contactMapping) return contactMapping } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 921b1c06b..7b5db7d21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -17,7 +17,7 @@ */ package org.thoughtcrime.securesms.database; -import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX; +import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; @@ -439,32 +439,6 @@ public class ThreadDatabase extends Database { return db.rawQuery(query, null); } - public int getUnapprovedConversationCount() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - - try { - String query = "SELECT COUNT (*) FROM " + TABLE_NAME + - " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + - " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + - GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; - cursor = db.rawQuery(query, null); - - if (cursor != null && cursor.moveToFirst()) - return cursor.getInt(0); - } finally { - if (cursor != null) - cursor.close(); - } - - return 0; - } - public long getLatestUnapprovedConversationTimestamp() { SQLiteDatabase db = databaseHelper.getReadableDatabase(); Cursor cursor = null; @@ -503,13 +477,15 @@ public class ThreadDatabase extends Database { } public Cursor getApprovedConversationList() { - String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+ LEGACY_CLOSED_GROUP_PREFIX +"%') " + + "OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; return getConversationList(where); } public Cursor getUnapprovedConversationList() { - String where = MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + + String where = "("+MESSAGE_COUNT + " != 0 OR "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" LIKE '"+IdPrefix.GROUP.getValue()+"%')" + + " AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; @@ -771,6 +747,8 @@ public class ThreadDatabase extends Database { if (shouldDeleteEmptyThread) { deleteThread(threadId); return true; + } else { + updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0); } return false; } @@ -828,8 +806,7 @@ public class ThreadDatabase extends Database { } private boolean deleteThreadOnEmpty(long threadId) { - Recipient threadRecipient = getRecipientForThreadId(threadId); - return threadRecipient != null && !threadRecipient.isOpenGroupRecipient(); + return false; } private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 3a3347a20..17fed47aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -89,11 +89,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV41 = 62; private static final int lokiV42 = 63; private static final int lokiV43 = 64; - private static final int lokiV44 = 65; + private static final int lokiV45 = 66; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV44; + private static final int DATABASE_VERSION = lokiV45; private static final int MIN_DATABASE_VERSION = lokiV7; private static final String CIPHER3_DATABASE_NAME = "signal.db"; public static final String DATABASE_NAME = "signal_v4.db"; @@ -360,6 +360,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); + + db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand()); + db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand()); } @Override @@ -610,6 +613,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs); } + if (oldVersion < lokiV45) { + db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand()); + db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index 936e4f287..6ef9afdb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -1,14 +1,20 @@ package org.thoughtcrime.securesms.dependencies +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.EntryPoint import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -22,8 +28,23 @@ abstract class AppModule { } +@Module +@InstallIn(SingletonComponent::class) +class ToasterModule { + @Provides + @Singleton + fun provideToaster(@ApplicationContext context: Context) = Toaster { stringRes, toastLength, parameters -> + val string = context.getString(stringRes, parameters) + Toast.makeText(context, string, toastLength).show() + } +} + @EntryPoint @InstallIn(SingletonComponent::class) interface AppComponent { fun getPrefs(): TextSecurePreferences +} + +fun interface Toaster { + fun toast(@StringRes stringRes: Int, toastLength: Int, vararg parameters: Any) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt index da15c2f6b..8e0c73577 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt @@ -1,16 +1,12 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context -import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ServiceComponent import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.scopes.ServiceScoped import dagger.hilt.components.SingletonComponent -import org.session.libsession.database.CallDataProvider -import org.thoughtcrime.securesms.database.Storage +import org.session.libsession.database.StorageProtocol import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import javax.inject.Singleton @@ -25,7 +21,7 @@ object CallModule { @Provides @Singleton - fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) = + fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) = CallManager(context, audioManagerCompat, storage) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index d664ffedb..e457dac0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -2,17 +2,25 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context import android.os.Trace +import network.loki.messenger.libsession_util.Config import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.GroupInfoConfig +import network.loki.messenger.libsession_util.GroupKeysConfig +import network.loki.messenger.libsession_util.GroupMembersConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile +import org.session.libsession.messaging.messages.Destination import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.groups.GroupManager @@ -61,7 +69,7 @@ class ConfigFactory( listeners -= listener } - private inline fun synchronizedWithLog(lock: Any, body: ()->T): T { + private inline fun synchronizedWithLog(lock: Any, body: () -> T): T { Trace.beginSection("synchronizedWithLog") val result = synchronized(lock) { body() @@ -72,7 +80,11 @@ class ConfigFactory( override val user: UserProfile? get() = synchronizedWithLog(userLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (!ConfigBase.isNewConfigEnabled( + isConfigForcedOn, + SnodeAPI.nowWithOffset + ) + ) return null if (_userConfig == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val userDump = configDatabase.retrieveConfigAndHashes( @@ -92,7 +104,11 @@ class ConfigFactory( override val contacts: Contacts? get() = synchronizedWithLog(contactsLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (!ConfigBase.isNewConfigEnabled( + isConfigForcedOn, + SnodeAPI.nowWithOffset + ) + ) return null if (_contacts == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val contactsDump = configDatabase.retrieveConfigAndHashes( @@ -112,7 +128,11 @@ class ConfigFactory( override val convoVolatile: ConversationVolatileConfig? get() = synchronizedWithLog(convoVolatileLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (!ConfigBase.isNewConfigEnabled( + isConfigForcedOn, + SnodeAPI.nowWithOffset + ) + ) return null if (_convoVolatileConfig == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val convoDump = configDatabase.retrieveConfigAndHashes( @@ -133,7 +153,11 @@ class ConfigFactory( override val userGroups: UserGroupsConfig? get() = synchronizedWithLog(userGroupsLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (!ConfigBase.isNewConfigEnabled( + isConfigForcedOn, + SnodeAPI.nowWithOffset + ) + ) return null if (_userGroups == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val userGroupsDump = configDatabase.retrieveConfigAndHashes( @@ -151,6 +175,86 @@ class ConfigFactory( _userGroups } + private fun getGroupAuthInfo(groupSessionId: SessionId) = userGroups?.getClosedGroup(groupSessionId.hexString())?.let { + it.adminKey to it.authData + } + + override fun getGroupInfoConfig(groupSessionId: SessionId): GroupInfoConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) -> + // get any potential initial dumps + val dump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.INFO_VARIANT, + groupSessionId.hexString() + ) ?: byteArrayOf() + + GroupInfoConfig.newInstance(Hex.fromStringCondensed(groupSessionId.publicKey), sk, dump) + } + + override fun getGroupKeysConfig(groupSessionId: SessionId, + info: GroupInfoConfig?, + members: GroupMembersConfig?, + free: Boolean): GroupKeysConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) -> + // Get the user info or return early + val (userSk, _) = maybeGetUserInfo() ?: return@let null + + // Get the group info or return early + val usedInfo = info ?: getGroupInfoConfig(groupSessionId) ?: return@let null + + // Get the group members or return early + val usedMembers = members ?: getGroupMemberConfig(groupSessionId) ?: return@let null + + // Get the dump or empty + val dump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.KEYS_VARIANT, + groupSessionId.hexString() + ) ?: byteArrayOf() + + // Put it all together + val keys = GroupKeysConfig.newInstance( + userSk, + Hex.fromStringCondensed(groupSessionId.publicKey), + sk, + dump, + usedInfo, + usedMembers + ) + if (free) { + info?.free() + members?.free() + } + if (usedInfo !== info) usedInfo.free() + if (usedMembers !== members) usedMembers.free() + keys + } + + override fun getGroupMemberConfig(groupSessionId: SessionId): GroupMembersConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, auth) -> + // Get initial dump if we have one + val dump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.MEMBER_VARIANT, + groupSessionId.hexString() + ) ?: byteArrayOf() + + GroupMembersConfig.newInstance( + Hex.fromStringCondensed(groupSessionId.publicKey), + sk, + dump + ) + } + + override fun constructGroupKeysConfig( + groupSessionId: SessionId, + info: GroupInfoConfig, + members: GroupMembersConfig + ): GroupKeysConfig? = getGroupAuthInfo(groupSessionId)?.let { (sk, _) -> + val (userSk, _) = maybeGetUserInfo() ?: return null + GroupKeysConfig.newInstance( + userSk, + Hex.fromStringCondensed(groupSessionId.publicKey), + sk, + info = info, + members = members + ) + } + override fun getUserConfigs(): List = listOfNotNull(user, contacts, convoVolatile, userGroups) @@ -158,13 +262,23 @@ class ConfigFactory( private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) { val dumped = user?.dump() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp) + configDatabase.storeConfig( + SharedConfigMessage.Kind.USER_PROFILE.name, + publicKey, + dumped, + timestamp + ) } private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) { val dumped = contacts?.dump() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp) + configDatabase.storeConfig( + SharedConfigMessage.Kind.CONTACTS.name, + publicKey, + dumped, + timestamp + ) } private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) { @@ -181,10 +295,30 @@ class ConfigFactory( private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) { val dumped = userGroups?.dump() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp) + configDatabase.storeConfig( + SharedConfigMessage.Kind.GROUPS.name, + publicKey, + dumped, + timestamp + ) } - override fun persist(forConfigObject: ConfigBase, timestamp: Long) { + fun persistGroupConfigDump(forConfigObject: ConfigBase, groupSessionId: SessionId, timestamp: Long) = synchronized(userGroupsLock) { + val dumped = forConfigObject.dump() + val variant = when (forConfigObject) { + is GroupMembersConfig -> ConfigDatabase.MEMBER_VARIANT + is GroupInfoConfig -> ConfigDatabase.INFO_VARIANT + else -> throw Exception("Shouldn't be called") + } + configDatabase.storeConfig( + variant, + groupSessionId.hexString(), + dumped, + timestamp + ) + } + + override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) { try { listeners.forEach { listener -> listener.notifyUpdates(forConfigObject) @@ -194,6 +328,8 @@ class ConfigFactory( is Contacts -> persistContactsConfigDump(timestamp) is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp) + is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, SessionId.from(forPublicKey!!), timestamp) + is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, SessionId.from(forPublicKey!!), timestamp) else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") } } catch (e: Exception) { @@ -214,23 +350,25 @@ class ConfigFactory( if (openGroupId != null) { val userGroups = userGroups ?: return false val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context) - val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false + val openGroup = + get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false // Not handling the `hidden` behaviour for communities so just indicate the existence return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null) - } - else if (groupPublicKey != null) { + } else if (groupPublicKey != null) { val userGroups = userGroups ?: return false // Not handling the `hidden` behaviour for legacy groups so just indicate the existence - return (userGroups.getLegacyGroupInfo(groupPublicKey) != null) - } - else if (publicKey == userPublicKey) { + return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) { + userGroups.getClosedGroup(groupPublicKey) != null + } else { + userGroups.getLegacyGroupInfo(groupPublicKey) != null + } + } else if (publicKey == userPublicKey) { val user = user ?: return false return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) - } - else if (publicKey != null) { + } else if (publicKey != null) { val contacts = contacts ?: return false val targetContact = contacts.get(publicKey) ?: return false @@ -240,12 +378,38 @@ class ConfigFactory( return false } - override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { + override fun canPerformChange( + variant: String, + publicKey: String, + changeTimestampMs: Long + ): Boolean { if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true - val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + val lastUpdateTimestampMs = + configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) // Ensure the change occurred after the last config message was handled (minus the buffer period) - return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod)) + return (changeTimestampMs >= (lastUpdateTimestampMs - configChangeBufferPeriod)) + } + + override fun saveGroupConfigs( + groupKeys: GroupKeysConfig, + groupInfo: GroupInfoConfig, + groupMembers: GroupMembersConfig + ) { + val pubKey = groupInfo.id().hexString() + val timestamp = SnodeAPI.nowWithOffset + configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp) + } + + override fun removeGroup(closedGroupId: SessionId) { + val groups = userGroups ?: return + groups.eraseClosedGroup(closedGroupId.hexString()) + configDatabase.deleteGroupConfigs(closedGroupId) + } + + override fun scheduleUpdate(destination: Destination) { + // there's probably a better way to do this + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt new file mode 100644 index 000000000..5a1dee052 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.dependencies + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.session.libsession.database.StorageProtocol +import org.thoughtcrime.securesms.database.Storage + +@Module +@InstallIn(SingletonComponent::class) +abstract class DatabaseBindings { + + @Binds + abstract fun bindStorageProtocol(storage: Storage): StorageProtocol + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index f2c046e0a..d37aa5ce2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -5,6 +5,7 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 524100190..2ad57c494 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -131,8 +131,13 @@ object DatabaseModule { @Provides @Singleton - fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { - val storage = Storage(context,openHelper, configFactory) + fun provideStorage(@ApplicationContext context: Context, + openHelper: SQLCipherOpenHelper, + configFactory: ConfigFactory, + threadDatabase: ThreadDatabase, + pollerFactory: PollerFactory, + toaster: Toaster): Storage { + val storage = Storage(context, openHelper, configFactory, pollerFactory, toaster) threadDatabase.setUpdateListener(storage) return storage } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt new file mode 100644 index 000000000..a0059cfc8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.dependencies + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.plus +import network.loki.messenger.libsession_util.util.GroupInfo +import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller +import org.session.libsignal.utilities.SessionId +import java.util.concurrent.ConcurrentHashMap + +class PollerFactory(private val scope: CoroutineScope, + private val executor: CoroutineDispatcher, + private val configFactory: ConfigFactory) { + + private val pollers = ConcurrentHashMap() + + fun pollerFor(sessionId: SessionId): ClosedGroupPoller? { + // Check if the group is currently in our config, don't start if it isn't + configFactory.userGroups?.getClosedGroup(sessionId.hexString()) ?: return null + + return pollers.getOrPut(sessionId) { + ClosedGroupPoller(scope + SupervisorJob(), executor, sessionId, configFactory) + } + } + + fun startAll() { + configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited)?.forEach { + pollerFor(it.groupSessionId)?.start() + } + } + + fun stopAll() { + pollers.forEach { (_, poller) -> + poller.stop() + } + } + + fun updatePollers() { + val currentGroups = configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited) ?: return + val toRemove = pollers.filter { (id, _) -> id !in currentGroups.map { it.groupSessionId } } + toRemove.forEach { (id, _) -> + pollers.remove(id)?.stop() + } + startAll() + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt index cd4b07133..7681536af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -6,16 +6,24 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.database.ConfigDatabase +import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object SessionUtilModule { + const val POLLER_SCOPE = "poller_coroutine_scope" + private fun maybeUserEdSecretKey(context: Context): ByteArray? { val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null return edKey.secretKey.asBytes @@ -33,4 +41,19 @@ object SessionUtilModule { registerListener(context as ConfigFactoryUpdateListener) } + @Provides + @Named(POLLER_SCOPE) + fun providePollerScope(@ApplicationContext applicationContext: Context): CoroutineScope = GlobalScope + + @OptIn(ExperimentalCoroutinesApi::class) + @Provides + @Named(POLLER_SCOPE) + fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + + @Provides + @Singleton + fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope, + @Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher, + configFactory: ConfigFactory) = PollerFactory(coroutineScope, dispatcher, configFactory) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt index f8e64dd38..e50461c9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -4,7 +4,7 @@ import android.content.Context import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil @@ -26,7 +26,7 @@ object ClosedGroupManager { // Notify the PN server PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) // Stop polling - ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey) storage.cancelPendingMessageSendJobs(threadId) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) if (delete) { @@ -34,16 +34,9 @@ object ClosedGroupManager { } } - fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean { - val groups = userGroups ?: return false - if (!group.isClosedGroup) return false - val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) - return groups.eraseLegacyGroup(groupPublicKey) - } - fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) { val groups = userGroups ?: return - if (!group.isClosedGroup) return + if (!group.isLegacyClosedGroup) return val storage = MessagingModuleConfiguration.shared.storage val threadId = storage.getThreadId(group.encodedId) ?: return val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index ecd40938a..36f4a9585 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -1,47 +1,44 @@ package org.thoughtcrime.securesms.groups -import android.content.Context import android.content.Intent import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.dependency +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R +import kotlinx.parcelize.Parcelize import network.loki.messenger.databinding.FragmentCreateGroupBinding -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.groupSizeLimit -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Device -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.contacts.SelectContactsAdapter +import org.session.libsession.messaging.contacts.Contact import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView -import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut -import javax.inject.Inject +import org.thoughtcrime.securesms.groups.compose.CreateGroup +import org.thoughtcrime.securesms.groups.compose.CreateGroupNavGraph +import org.thoughtcrime.securesms.groups.compose.SelectContacts +import org.thoughtcrime.securesms.groups.compose.StateUpdate +import org.thoughtcrime.securesms.groups.compose.ViewState +import org.thoughtcrime.securesms.groups.destinations.SelectContactsScreenDestination +import org.thoughtcrime.securesms.ui.AppTheme @AndroidEntryPoint class CreateGroupFragment : Fragment() { - @Inject - lateinit var device: Device - private lateinit var binding: FragmentCreateGroupBinding - private val viewModel: CreateGroupViewModel by viewModels() lateinit var delegate: NewConversationDelegate @@ -49,76 +46,91 @@ class CreateGroupFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = FragmentCreateGroupBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val adapter = SelectContactsAdapter(requireContext(), GlideApp.with(requireContext())) - binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks { - override fun onQueryChanged(query: String) { - adapter.members = viewModel.filter(query).map { it.address.serialize() } + return ComposeView(requireContext()).apply { + val getDelegate = { delegate } + setContent { + AppTheme { + DestinationsNavHost( + navGraph = NavGraphs.createGroup, + dependenciesContainerBuilder = { + dependency(getDelegate) + }) + } } } - binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } - binding.recyclerView.adapter = adapter - val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let { - DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply { - setDrawable(it) - } - } - binding.recyclerView.addItemDecoration(divider) - var isLoading = false - binding.createClosedGroupButton.setOnClickListener { - if (isLoading) return@setOnClickListener - val name = binding.nameEditText.text.trim() - if (name.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() - } - if (name.length >= 30) { - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show() - } - val selectedMembers = adapter.selectedMembers - if (selectedMembers.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() - } - if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() - } - val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!! - isLoading = true - binding.loaderContainer.fadeIn() - MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID -> - binding.loaderContainer.fadeOut() - isLoading = false - val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false)) - openConversationActivity( - requireContext(), - threadID, - Recipient.from(requireContext(), Address.fromSerialized(groupID), false) - ) - delegate.onDialogClosePressed() - }.failUi { - binding.loaderContainer.fadeOut() - isLoading = false - Toast.makeText(context, it.message, Toast.LENGTH_LONG).show() - } - } - binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty() - binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty() - viewModel.recipients.observe(viewLifecycleOwner) { recipients -> - adapter.members = recipients.map { it.address.serialize() } - } } - private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - context.startActivity(intent) +} + +@Parcelize +data class ContactList(val contacts: Set) : Parcelable + +@CreateGroupNavGraph(start = true) +@Composable +@Destination +fun CreateGroupScreen( + navigator: DestinationsNavigator, + resultSelectContact: ResultRecipient, + viewModel: CreateGroupViewModel = hiltViewModel(), + getDelegate: () -> NewConversationDelegate +) { + val viewState by viewModel.viewState.observeAsState(ViewState.DEFAULT) + + resultSelectContact.onNavResult { navResult -> + when (navResult) { + is NavResult.Value -> { + viewModel.updateState(StateUpdate.AddContacts(navResult.value.contacts)) + } + + is NavResult.Canceled -> { /* do nothing */ + } + } } + val context = LocalContext.current + + viewState.createdGroup?.let { group -> + SideEffect { + getDelegate().onDialogClosePressed() + val intent = Intent(context, ConversationActivityV2::class.java).apply { + putExtra(ConversationActivityV2.ADDRESS, group.address) + } + context.startActivity(intent) + } + } + + CreateGroup( + viewState, + viewModel::updateState, + onClose = { + getDelegate().onDialogClosePressed() + }, + onSelectContact = { navigator.navigate(SelectContactsScreenDestination) }, + onBack = { + getDelegate().onDialogBackPressed() + } + ) +} + +@CreateGroupNavGraph +@Composable +@Destination +fun SelectContactsScreen( + resultNavigator: ResultBackNavigator, + viewModel: CreateGroupViewModel = hiltViewModel(), + getDelegate: () -> NewConversationDelegate +) { + + val viewState by viewModel.viewState.observeAsState(ViewState.DEFAULT) + val currentMembers = viewState.members + val contacts by viewModel.contacts.observeAsState(initial = emptySet()) + + SelectContacts( + contacts - currentMembers, + onBack = { resultNavigator.navigateBack() }, + onClose = { getDelegate().onDialogClosePressed() }, + onContactsSelected = { + resultNavigator.navigateBack(ContactList(it)) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt index b3dbb4938..b88f08348 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -3,44 +3,80 @@ package org.thoughtcrime.securesms.groups import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.ThreadDatabase +import network.loki.messenger.R +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.groups.compose.StateUpdate +import org.thoughtcrime.securesms.groups.compose.ViewState import javax.inject.Inject @HiltViewModel class CreateGroupViewModel @Inject constructor( - private val threadDb: ThreadDatabase, - private val textSecurePreferences: TextSecurePreferences + private val storage: Storage, ) : ViewModel() { - private val _recipients = MutableLiveData>() - val recipients: LiveData> = _recipients + private inline fun MutableLiveData.update(body: T.() -> T) { + this.postValue(body(this.value!!)) + } - init { - viewModelScope.launch { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val recipients = mutableListOf() - while (true) { - recipients += reader.next?.recipient ?: break - } - withContext(Dispatchers.Main) { - _recipients.value = recipients - .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } - } - } + private val _viewState = MutableLiveData(ViewState.DEFAULT.copy()) + + val viewState: LiveData = _viewState + + fun updateState(stateUpdate: StateUpdate) { + when (stateUpdate) { + is StateUpdate.AddContacts -> _viewState.update { copy(members = members + stateUpdate.value) } + is StateUpdate.Description -> _viewState.update { copy(description = stateUpdate.value) } + is StateUpdate.Name -> _viewState.update { copy(name = stateUpdate.value) } + is StateUpdate.RemoveContact -> _viewState.update { copy(members = members - stateUpdate.value) } + StateUpdate.Create -> { viewModelScope.launch { tryCreateGroup() } } } } - fun filter(query: String): List { - return _recipients.value?.filter { - it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true - } ?: emptyList() + val contacts + get() = liveData { emit(storage.getAllContacts()) } + + fun tryCreateGroup() { + + val currentState = _viewState.value!! + + _viewState.postValue(currentState.copy(isLoading = true, error = null)) + + val name = currentState.name + val description = currentState.description + val members = currentState.members.toMutableSet() + + // do some validation + // need a name + if (name.isEmpty()) { + return _viewState.postValue( + currentState.copy(isLoading = false, error = R.string.error) + ) + } + + if (members.size <= 1) { + _viewState.postValue( + currentState.copy( + isLoading = false, + error = R.string.activity_create_closed_group_not_enough_group_members_error + ) + ) + } + + // make a group + val newGroup = storage.createNewGroup(name, description, members) + if (!newGroup.isPresent) { + // show a generic couldn't create or something? + return _viewState.postValue(currentState.copy(isLoading = false, error = null)) + } else { + return _viewState.postValue(currentState.copy( + isLoading = false, + error = null, + createdGroup = newGroup.get()) + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt index 9fee8adaf..9f4e00a7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -1,342 +1,50 @@ package org.thoughtcrime.securesms.groups -import android.content.Context -import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.TextView -import android.widget.Toast -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.activity.compose.setContent +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.dependency import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.task -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.groupSizeLimit -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.ThemeUtil -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.contacts.SelectContactsActivity -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup -import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut -import java.io.IOException +import org.thoughtcrime.securesms.groups.compose.EditGroupInviteViewModel +import org.thoughtcrime.securesms.groups.compose.EditGroupViewModel +import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupScreenDestination +import org.thoughtcrime.securesms.ui.AppTheme import javax.inject.Inject @AndroidEntryPoint -class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { - - @Inject - lateinit var groupConfigFactory: ConfigFactory - @Inject - lateinit var storage: Storage - - private val originalMembers = HashSet() - private val zombies = HashSet() - private val members = HashSet() - private val allMembers: Set - get() { - return members + zombies - } - private var hasNameChanged = false - private var isSelfAdmin = false - private var isLoading = false - set(newValue) { field = newValue; invalidateOptionsMenu() } - - private lateinit var groupID: String - private lateinit var originalName: String - private lateinit var name: String - - private var isEditingName = false - set(value) { - if (field == value) return - field = value - handleIsEditingNameChanged() - } - - private val memberListAdapter by lazy { - if (isSelfAdmin) - EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick) - else - EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin) - } - - private lateinit var mainContentContainer: LinearLayout - private lateinit var cntGroupNameEdit: LinearLayout - private lateinit var cntGroupNameDisplay: LinearLayout - private lateinit var edtGroupName: EditText - private lateinit var emptyStateContainer: LinearLayout - private lateinit var lblGroupNameDisplay: TextView - private lateinit var loaderContainer: View +class EditClosedGroupActivity: PassphraseRequiredActionBarActivity() { companion object { - @JvmStatic val groupIDKey = "groupIDKey" - private val loaderID = 0 - val addUsersRequestCode = 124 - val legacyGroupSizeLimit = 10 + const val groupIDKey = "EditClosedGroupActivity_groupID" } - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - setContentView(R.layout.activity_edit_closed_group) + @Inject lateinit var editFactory: EditGroupViewModel.Factory + @Inject lateinit var inviteFactory: EditGroupInviteViewModel.Factory - supportActionBar!!.setHomeAsUpIndicator( - ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable)) - - groupID = intent.getStringExtra(groupIDKey)!! - val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get() - originalName = groupInfo.title - isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) } - - name = originalName - - mainContentContainer = findViewById(R.id.mainContentContainer) - cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit) - cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay) - edtGroupName = findViewById(R.id.edtGroupName) - emptyStateContainer = findViewById(R.id.emptyStateContainer) - lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay) - loaderContainer = findViewById(R.id.loaderContainer) - - findViewById(R.id.addMembersClosedGroupButton).setOnClickListener { - onAddMembersClick() - } - - findViewById(R.id.rvUserList).apply { - adapter = memberListAdapter - layoutManager = LinearLayoutManager(this@EditClosedGroupActivity) - } - - lblGroupNameDisplay.text = originalName - cntGroupNameDisplay.setOnClickListener { isEditingName = true } - findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false } - findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() } - edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE) - edtGroupName.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> { - saveName() - return@setOnEditorActionListener true - } - else -> return@setOnEditorActionListener false - } - } - - LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks { - - override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID) - } - - override fun onLoadFinished(loader: Loader, groupMembers: GroupMembers) { - // We no longer need any subsequent loading events - // (they will occur on every activity resume). - LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID) - - members.clear() - members.addAll(groupMembers.members.toHashSet()) - zombies.clear() - zombies.addAll(groupMembers.zombieMembers.toHashSet()) - originalMembers.clear() - originalMembers.addAll(members + zombies) - updateMembers() - } - - override fun onLoaderReset(loader: Loader) { - updateMembers() - } - }) + private fun onFinish() { + finish() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_edit_closed_group, menu) - return allMembers.isNotEmpty() && !isLoading - } - // endregion + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + setContent { - // region Updating - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - when (requestCode) { - addUsersRequestCode -> { - if (resultCode != RESULT_OK) return - if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return - - val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet() - members.addAll(selectedContacts) - updateMembers() - } - } - } - - private fun handleIsEditingNameChanged() { - cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE - cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE - val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (isEditingName) { - edtGroupName.setText(name) - edtGroupName.selectAll() - edtGroupName.requestFocus() - inputMethodManager.showSoftInput(edtGroupName, 0) - } else { - inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0) - } - } - - private fun updateMembers() { - memberListAdapter.setMembers(allMembers) - memberListAdapter.setZombieMembers(zombies) - - mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE - emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE - - invalidateOptionsMenu() - } - // endregion - - // region Interaction - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_apply -> if (!isLoading) { commitChanges() } - } - return super.onOptionsItemSelected(item) - } - - private fun onMemberClick(member: String) { - val bottomSheet = ClosedGroupEditingOptionsBottomSheet() - bottomSheet.onRemoveTapped = { - if (zombies.contains(member)) zombies.remove(member) - else members.remove(member) - updateMembers() - bottomSheet.dismiss() - } - bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet") - } - - private fun onAddMembersClick() { - val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java) - intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) - intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") - startActivityForResult(intent, addUsersRequestCode) - } - - private fun saveName() { - val name = edtGroupName.text.toString().trim() - if (name.isEmpty()) { - return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show() - } - if (name.length >= 64) { - return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show() - } - this.name = name - lblGroupNameDisplay.text = name - hasNameChanged = true - isEditingName = false - } - - private fun commitChanges() { - val hasMemberListChanges = (allMembers != originalMembers) - - if (!hasNameChanged && !hasMemberListChanges) { - return finish() - } - - val name = if (hasNameChanged) this.name else originalName - - val members = this.allMembers.map { - Recipient.from(this, Address.fromSerialized(it), false) - }.toSet() - val originalMembers = this.originalMembers.map { - Recipient.from(this, Address.fromSerialized(it), false) - }.toSet() - - var isClosedGroup: Boolean - var groupPublicKey: String? - try { - groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString() - isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey) - } catch (e: IOException) { - groupPublicKey = null - isClosedGroup = false - } - - if (members.isEmpty()) { - return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() - } - - val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit - if (members.size >= maxGroupMembers) { - return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() - } - - val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! - val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false) - - if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) { - val message = "Can't leave while adding or removing other members." - return Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() - } - - if (isClosedGroup) { - isLoading = true - loaderContainer.fadeIn() - val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { - MessageSender.explicitLeave(groupPublicKey!!, false) - } else { - task { - if (hasNameChanged) { - MessageSender.explicitNameChange(groupPublicKey!!, name) + AppTheme { + DestinationsNavHost( + navGraph = NavGraphs.editGroup, + dependenciesContainerBuilder = { + dependency(NavGraphs.editGroup) { + editFactory.create(intent.getStringExtra(groupIDKey)!!, contentResolver) + } + dependency(NavGraphs.editGroup) { + inviteFactory.create(intent.getStringExtra(groupIDKey)!!) + } + dependency(EditClosedGroupScreenDestination) { + ::onFinish + } } - members.filterNot { it in originalMembers }.let { adds -> - if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() }) - } - originalMembers.filterNot { it in members }.let { removes -> - if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) - } - } - } - promise.successUi { - loaderContainer.fadeOut() - isLoading = false - updateGroupConfig() - finish() - }.failUi { exception -> - val message = if (exception is MessageSender.Error) exception.description else "An error occurred" - Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() - loaderContainer.fadeOut() - isLoading = false + ) } } } - - private fun updateGroupConfig() { - val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID)) - ?: return Log.w("Loki", "No recipient settings when trying to update group config") - val latestGroup = storage.getGroup(groupID) - ?: return Log.w("Loki", "No group record when trying to update group config") - groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup) - } - - class GroupMembers(val members: List, val zombieMembers: List) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt index b1e0b5e1d..2acdfcd78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt @@ -4,13 +4,13 @@ import android.content.Context import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.AsyncLoader -class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) { +class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) { - override fun loadInBackground(): EditClosedGroupActivity.GroupMembers { + override fun loadInBackground(): EditLegacyClosedGroupActivity.GroupMembers { val groupDatabase = DatabaseComponent.get(context).groupDatabase() val members = groupDatabase.getGroupMembers(groupID, true) val zombieMembers = groupDatabase.getGroupZombieMembers(groupID) - return EditClosedGroupActivity.GroupMembers( + return EditLegacyClosedGroupActivity.GroupMembers( members.map { it.address.toString() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupActivity.kt new file mode 100644 index 000000000..fb572f0f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupActivity.kt @@ -0,0 +1,342 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.task +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.groupSizeLimit +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.ThemeUtil +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.contacts.SelectContactsActivity +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.fadeIn +import org.thoughtcrime.securesms.util.fadeOut +import java.io.IOException +import javax.inject.Inject + +@AndroidEntryPoint +class EditLegacyClosedGroupActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var groupConfigFactory: ConfigFactory + @Inject + lateinit var storage: Storage + + private val originalMembers = HashSet() + private val zombies = HashSet() + private val members = HashSet() + private val allMembers: Set + get() { + return members + zombies + } + private var hasNameChanged = false + private var isSelfAdmin = false + private var isLoading = false + set(newValue) { field = newValue; invalidateOptionsMenu() } + + private lateinit var groupID: String + private lateinit var originalName: String + private lateinit var name: String + + private var isEditingName = false + set(value) { + if (field == value) return + field = value + handleIsEditingNameChanged() + } + + private val memberListAdapter by lazy { + if (isSelfAdmin) + EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick) + else + EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin) + } + + private lateinit var mainContentContainer: LinearLayout + private lateinit var cntGroupNameEdit: LinearLayout + private lateinit var cntGroupNameDisplay: LinearLayout + private lateinit var edtGroupName: EditText + private lateinit var emptyStateContainer: LinearLayout + private lateinit var lblGroupNameDisplay: TextView + private lateinit var loaderContainer: View + + companion object { + @JvmStatic val groupIDKey = "groupIDKey" + private val loaderID = 0 + val addUsersRequestCode = 124 + val legacyGroupSizeLimit = 10 + } + + // region Lifecycle + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { + super.onCreate(savedInstanceState, isReady) + setContentView(R.layout.activity_edit_closed_group) + + supportActionBar!!.setHomeAsUpIndicator( + ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable)) + + groupID = intent.getStringExtra(groupIDKey)!! + val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get() + originalName = groupInfo.title + isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) } + + name = originalName + + mainContentContainer = findViewById(R.id.mainContentContainer) + cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit) + cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay) + edtGroupName = findViewById(R.id.edtGroupName) + emptyStateContainer = findViewById(R.id.emptyStateContainer) + lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay) + loaderContainer = findViewById(R.id.loaderContainer) + + findViewById(R.id.addMembersClosedGroupButton).setOnClickListener { + onAddMembersClick() + } + + findViewById(R.id.rvUserList).apply { + adapter = memberListAdapter + layoutManager = LinearLayoutManager(this@EditLegacyClosedGroupActivity) + } + + lblGroupNameDisplay.text = originalName + cntGroupNameDisplay.setOnClickListener { isEditingName = true } + findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false } + findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() } + edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE) + edtGroupName.setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_DONE -> { + saveName() + return@setOnEditorActionListener true + } + else -> return@setOnEditorActionListener false + } + } + + LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks { + + override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { + return EditClosedGroupLoader(this@EditLegacyClosedGroupActivity, groupID) + } + + override fun onLoadFinished(loader: Loader, groupMembers: GroupMembers) { + // We no longer need any subsequent loading events + // (they will occur on every activity resume). + LoaderManager.getInstance(this@EditLegacyClosedGroupActivity).destroyLoader(loaderID) + + members.clear() + members.addAll(groupMembers.members.toHashSet()) + zombies.clear() + zombies.addAll(groupMembers.zombieMembers.toHashSet()) + originalMembers.clear() + originalMembers.addAll(members + zombies) + updateMembers() + } + + override fun onLoaderReset(loader: Loader) { + updateMembers() + } + }) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_edit_closed_group, menu) + return allMembers.isNotEmpty() && !isLoading + } + // endregion + + // region Updating + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + addUsersRequestCode -> { + if (resultCode != RESULT_OK) return + if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return + + val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet() + members.addAll(selectedContacts) + updateMembers() + } + } + } + + private fun handleIsEditingNameChanged() { + cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE + cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE + val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (isEditingName) { + edtGroupName.setText(name) + edtGroupName.selectAll() + edtGroupName.requestFocus() + inputMethodManager.showSoftInput(edtGroupName, 0) + } else { + inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0) + } + } + + private fun updateMembers() { + memberListAdapter.setMembers(allMembers) + memberListAdapter.setZombieMembers(zombies) + + mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE + emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE + + invalidateOptionsMenu() + } + // endregion + + // region Interaction + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_apply -> if (!isLoading) { commitChanges() } + } + return super.onOptionsItemSelected(item) + } + + private fun onMemberClick(member: String) { + val bottomSheet = ClosedGroupEditingOptionsBottomSheet() + bottomSheet.onRemoveTapped = { + if (zombies.contains(member)) zombies.remove(member) + else members.remove(member) + updateMembers() + bottomSheet.dismiss() + } + bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet") + } + + private fun onAddMembersClick() { + val intent = Intent(this@EditLegacyClosedGroupActivity, SelectContactsActivity::class.java) + intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) + intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") + startActivityForResult(intent, addUsersRequestCode) + } + + private fun saveName() { + val name = edtGroupName.text.toString().trim() + if (name.isEmpty()) { + return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show() + } + if (name.length >= 64) { + return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show() + } + this.name = name + lblGroupNameDisplay.text = name + hasNameChanged = true + isEditingName = false + } + + private fun commitChanges() { + val hasMemberListChanges = (allMembers != originalMembers) + + if (!hasNameChanged && !hasMemberListChanges) { + return finish() + } + + val name = if (hasNameChanged) this.name else originalName + + val members = this.allMembers.map { + Recipient.from(this, Address.fromSerialized(it), false) + }.toSet() + val originalMembers = this.originalMembers.map { + Recipient.from(this, Address.fromSerialized(it), false) + }.toSet() + + var isClosedGroup: Boolean + var groupPublicKey: String? + try { + groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString() + isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey) + } catch (e: IOException) { + groupPublicKey = null + isClosedGroup = false + } + + if (members.isEmpty()) { + return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() + } + + val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit + if (members.size >= maxGroupMembers) { + return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() + } + + val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! + val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false) + + if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) { + val message = "Can't leave while adding or removing other members." + return Toast.makeText(this@EditLegacyClosedGroupActivity, message, Toast.LENGTH_LONG).show() + } + + if (isClosedGroup) { + isLoading = true + loaderContainer.fadeIn() + val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { + MessageSender.explicitLeave(groupPublicKey!!, false) + } else { + task { + if (hasNameChanged) { + MessageSender.explicitNameChange(groupPublicKey!!, name) + } + members.filterNot { it in originalMembers }.let { adds -> + if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() }) + } + originalMembers.filterNot { it in members }.let { removes -> + if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) + } + } + } + promise.successUi { + loaderContainer.fadeOut() + isLoading = false + updateGroupConfig() + finish() + }.failUi { exception -> + val message = if (exception is MessageSender.Error) exception.description else "An error occurred" + Toast.makeText(this@EditLegacyClosedGroupActivity, message, Toast.LENGTH_LONG).show() + loaderContainer.fadeOut() + isLoading = false + } + } + } + + private fun updateGroupConfig() { + val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID)) + ?: return Log.w("Loki", "No recipient settings when trying to update group config") + val latestGroup = storage.getGroup(groupID) + ?: return Log.w("Loki", "No group record when trying to update group config") + groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup) + } + + class GroupMembers(val members: List, val zombieMembers: List) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt new file mode 100644 index 000000000..0b18b4e7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt @@ -0,0 +1,2 @@ +package org.thoughtcrime.securesms.groups + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt new file mode 100644 index 000000000..8da92c685 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -0,0 +1,190 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.home.search.getSearchName +import org.thoughtcrime.securesms.ui.Avatar +import org.thoughtcrime.securesms.ui.LocalPreviewMode +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider + + +@Composable +fun EmptyPlaceholder(modifier: Modifier = Modifier) { + Column(modifier) { + Text( + text = stringResource(id = R.string.activity_create_closed_group_empty_placeholer), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } +} + +fun LazyListScope.multiSelectMemberList( + contacts: List, + modifier: Modifier = Modifier, + selectedContacts: Set = emptySet(), + onListUpdated: (Set)->Unit = {}, +) { + items(contacts) { contact -> + val isSelected = selectedContacts.contains(contact) + + val update = { + val newList = + if (isSelected) selectedContacts - contact + else selectedContacts + contact + onListUpdated(newList) + } + + Row(modifier = modifier.fillMaxWidth() + .clickable(onClick = update) + .padding(vertical = 8.dp, horizontal = 24.dp), + verticalAlignment = CenterVertically + ) { + ContactPhoto( + contact.sessionID, + modifier = Modifier + .size(48.dp) + ) + MemberName(name = contact.getSearchName(), modifier = Modifier.padding(16.dp)) + RadioButton(selected = isSelected, onClick = update) + } + } +} + +val MemberNameStyle = TextStyle(fontWeight = FontWeight.Bold) + +@Composable +fun RowScope.MemberName( + name: String, + modifier: Modifier = Modifier +) = Text( + text = name, + style = MemberNameStyle, + modifier = modifier + .weight(1f) + .align(CenterVertically) +) + +fun LazyListScope.deleteMemberList( + contacts: List, + modifier: Modifier = Modifier, + onDelete: (Contact) -> Unit, +) { + item { + Text( + text = stringResource(id = R.string.conversation_settings_group_members), + style = MaterialTheme.typography.subtitle2, + modifier = modifier + .padding(vertical = 8.dp) + ) + } + if (contacts.isEmpty()) { + item { + EmptyPlaceholder(modifier.fillMaxWidth()) + } + } else { + items(contacts) { contact -> + Row(modifier.fillMaxWidth()) { + ContactPhoto( + contact.sessionID, + modifier = Modifier + .size(48.dp) + .align(CenterVertically) + ) + MemberName(name = contact.getSearchName(), modifier = Modifier.padding(16.dp)) + Image( + painterResource(id = R.drawable.ic_baseline_close_24), + null, + modifier = Modifier + .size(32.dp) + .align(CenterVertically) + .clickable { + onDelete(contact) + }, + ) + } + } + } +} + + +@Composable +fun RowScope.ContactPhoto(sessionId: String, modifier: Modifier = Modifier) { + return if (LocalPreviewMode.current) { + Image( + painterResource(id = R.drawable.ic_profile_default), + colorFilter = ColorFilter.tint(MaterialTheme.colors.onPrimary), + contentScale = ContentScale.Inside, + contentDescription = null, + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colors.onPrimary, CircleShape) + ) + } else { + val context = LocalContext.current + // Ideally we migrate to something that doesn't require recipient, or get contact photo another way + val recipient = remember(sessionId) { + Recipient.from(context, Address.fromSerialized(sessionId), false) + } + Avatar(recipient) + } +} + + +@Preview +@Composable +fun PreviewMemberList( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val previewMembers = setOf( + Contact(random).apply { + name = "Person" + } + ) + PreviewTheme(themeResId = themeResId) { + LazyColumn { + multiSelectMemberList( + contacts = previewMembers.toList(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroup.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroup.kt new file mode 100644 index 000000000..52c86ee4e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroup.kt @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin +import org.thoughtcrime.securesms.ui.CloseIcon +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.EditableAvatar +import org.thoughtcrime.securesms.ui.NavigationBar +import org.thoughtcrime.securesms.ui.PreviewTheme + + +@Composable +fun CreateGroup( + viewState: ViewState, + updateState: (StateUpdate) -> Unit, + onSelectContact: () -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + + val lazyState = rememberLazyListState() + + Box { + Column( + modifier + .fillMaxWidth()) { + LazyColumn(state = lazyState) { + // Top bar + item { + Column(modifier.fillMaxWidth()) { + NavigationBar( + title = stringResource(id = R.string.activity_create_group_title), + onBack = onBack, + actionElement = { CloseIcon(onClose) } + ) + // Editable avatar (future chunk) + EditableAvatar( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp) + ) + // Title + val nameDescription = stringResource(id = R.string.AccessibilityId_closed_group_edit_group_name) + OutlinedTextField( + value = viewState.name, + onValueChange = { updateState(StateUpdate.Name(it)) }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp, horizontal = 24.dp) + .semantics { + contentDescription = nameDescription + }, + ) + // Description + val descriptionDescription = stringResource(id = R.string.AccessibilityId_closed_group_edit_group_description) + OutlinedTextField( + value = viewState.description, + onValueChange = { updateState(StateUpdate.Description(it)) }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp, horizontal = 24.dp) + .semantics { + contentDescription = descriptionDescription + }, + ) + + CellWithPaddingAndMargin(padding = 0.dp) { + Column(Modifier.fillMaxSize()) { + // Select Contacts + val padding = Modifier + .padding(8.dp) + .fillMaxWidth() + Row(padding.clickable { + onSelectContact() + }) { + Image( + painterResource(id = R.drawable.ic_person_white_24dp), + null, + Modifier + .padding(4.dp) + .align(Alignment.CenterVertically) + ) + Text( + stringResource(id = R.string.activity_create_closed_group_select_contacts), + Modifier + .padding(4.dp) + .align(Alignment.CenterVertically) + ) + } + Divider() + // Add account ID or ONS + Row(padding) { + Image( + painterResource(id = R.drawable.ic_baseline_add_24), + null, + Modifier + .padding(4.dp) + .align(Alignment.CenterVertically) + ) + Text( + stringResource(id = R.string.activity_create_closed_group_add_account_or_ons), + Modifier + .padding(4.dp) + .align(Alignment.CenterVertically) + ) + } + } + } + } + } + // Group list + deleteMemberList(contacts = viewState.members, modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp)) { deletedContact -> + updateState(StateUpdate.RemoveContact(deletedContact)) + } + } + // Create button + val createDescription = stringResource(id = R.string.AccessibilityId_create_closed_group_create_button) + OutlinedButton( + onClick = { updateState(StateUpdate.Create) }, + enabled = viewState.canCreate, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + .semantics { + contentDescription = createDescription + } + , + shape = RoundedCornerShape(32.dp) + ) { + Text( + text = stringResource(id = R.string.activity_create_group_create_button_title), + // TODO: colours of everything here probably needs to be redone + color = MaterialTheme.colors.onBackground, + modifier = Modifier.width(160.dp), + textAlign = TextAlign.Center + ) + } + } + if (viewState.isLoading) { + Box(modifier = modifier + .fillMaxSize() + .background(Color.Gray.copy(alpha = 0.5f))) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colors.secondary + ) + } + } + } +} + +@Preview +@Composable +fun ClosedGroupPreview( +) { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val previewMembers = setOf( + Contact(random).apply { + name = "Person" + } + ) + PreviewTheme(R.style.Theme_Session_DayNight_NoActionBar_Test) { + CreateGroup( + viewState = ViewState.DEFAULT.copy( + // override any preview parameters + members = previewMembers.toList() + ), + updateState = {}, + onSelectContact = {}, + onBack = {}, + onClose = {}, + ) + } +} + +data class ViewState( + val isLoading: Boolean, + @StringRes val error: Int?, + val name: String = "", + val description: String = "", + val members: List = emptyList(), + val createdGroup: Recipient? = null, + ) { + + val canCreate + get() = name.isNotEmpty() && members.isNotEmpty() + + companion object { + val DEFAULT = ViewState(false, null) + } + +} + +sealed class StateUpdate { + data object Create: StateUpdate() + data class Name(val value: String): StateUpdate() + data class Description(val value: String): StateUpdate() + data class RemoveContact(val value: Contact): StateUpdate() + data class AddContacts(val value: Set): StateUpdate() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupNavGraph.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupNavGraph.kt new file mode 100644 index 000000000..b3dc0170c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupNavGraph.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups.compose + +import com.ramcosta.composedestinations.annotation.NavGraph + +@NavGraph +annotation class CreateGroupNavGraph( + val start: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt new file mode 100644 index 000000000..417907b3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt @@ -0,0 +1,673 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.content.ContentResolver +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.cash.molecule.RecompositionMode.Immediate +import app.cash.molecule.launchMolecule +import com.google.android.material.color.MaterialColors +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.spec.DestinationStyle +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.GroupMember +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.jobs.InviteContactsJob +import org.session.libsession.messaging.jobs.JobQueue +import org.thoughtcrime.securesms.groups.ContactList +import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupInviteScreenDestination +import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupNameScreenDestination +import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin +import org.thoughtcrime.securesms.ui.LocalExtraColors +import org.thoughtcrime.securesms.ui.NavigationBar +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider + +@EditGroupNavGraph(start = true) +@Composable +@Destination +fun EditClosedGroupScreen( + navigator: DestinationsNavigator, + resultSelectContact: ResultRecipient, + resultEditName: ResultRecipient, + viewModel: EditGroupViewModel, + onFinish: () -> Unit +) { + val group by viewModel.viewState.collectAsState() + val context = LocalContext.current + val viewState = group.viewState + val eventSink = group.eventSink + + resultSelectContact.onNavResult { navResult -> + if (navResult is NavResult.Value) { + eventSink(EditGroupEvent.InviteContacts(context, navResult.value)) + } + } + + resultEditName.onNavResult { navResult -> + if (navResult is NavResult.Value) { + eventSink(EditGroupEvent.ChangeName(navResult.value)) + } + } + + EditGroupView( + onBack = { + onFinish() + }, + onInvite = { + navigator.navigate(EditClosedGroupInviteScreenDestination) + }, + onReinvite = { contact -> + eventSink(EditGroupEvent.ReInviteContact(contact)) + }, + onPromote = { contact -> + eventSink(EditGroupEvent.PromoteContact(contact)) + }, + onRemove = { contact -> + eventSink(EditGroupEvent.RemoveContact(contact)) + }, + onEditName = { + navigator.navigate(EditClosedGroupNameScreenDestination) + }, + viewState = viewState + ) +} + +@EditGroupNavGraph +@Composable +@Destination(style = DestinationStyle.Dialog::class) +fun EditClosedGroupNameScreen( + resultNavigator: ResultBackNavigator, + viewModel: EditGroupViewModel +) { + EditClosedGroupView { name -> + if (name.isEmpty()) { + resultNavigator.navigateBack() + } else { + resultNavigator.navigateBack(name) + } + } +} + +@Composable +fun EditClosedGroupView(navigateBack: (String) -> Unit) { + + var newName by remember { + mutableStateOf("") + } + + var newDescription by remember { + mutableStateOf("") + } + + Box( + Modifier + .fillMaxWidth() + .shadow(8.dp) + .background(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.dialog_edit_group_information_title), + modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = R.string.dialog_edit_group_information_message), + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + maxLines = 1, + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.dialog_edit_group_information_enter_group_name) + ) + } + ) + OutlinedTextField( + value = newDescription, + onValueChange = { newDescription = it }, + modifier = Modifier + .fillMaxWidth(), + minLines = 2, + maxLines = 2, + placeholder = { + Text( + text = stringResource(id = R.string.dialog_edit_group_information_enter_group_description) + ) + } + ) + Row(modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.save), + modifier = Modifier + .padding(16.dp) + .weight(1f) + .clickable { + navigateBack(newName) + }, + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.cancel), + modifier = Modifier + .padding(16.dp) + .weight(1f) + .clickable { + navigateBack("") + }, + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = LocalExtraColors.current.destructive + ) + } + } + } +} + +@EditGroupNavGraph +@Composable +@Destination +fun EditClosedGroupInviteScreen( + resultNavigator: ResultBackNavigator, + viewModel: EditGroupInviteViewModel, +) { + + val state by viewModel.viewState.collectAsState() + val viewState = state.viewState + val currentMemberSessionIds = viewState.currentMembers.map { it.memberSessionId } + + SelectContacts( + viewState.allContacts + .filterNot { it.sessionID in currentMemberSessionIds } + .toSet(), + onBack = { resultNavigator.navigateBack() }, + onContactsSelected = { + resultNavigator.navigateBack(ContactList(it)) + }, + ) +} + + +class EditGroupViewModel @AssistedInject constructor( + @Assisted private val groupSessionId: String, + @Assisted private val contentResolver: ContentResolver, + private val storage: StorageProtocol, +): ViewModel() { + + val viewState = viewModelScope.launchMolecule(Immediate) { + + val currentUserId = rememberSaveable { + storage.getUserPublicKey()!! + } + +// val closedGroupRecipient by contentResolver +// .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI) +// .collectAsState(initial = null) + + val closedGroupInfo = remember { + storage.getLibSessionClosedGroup(groupSessionId)!! + } + + val closedGroup = remember(closedGroupInfo) { + storage.getClosedGroupDisplayInfo(groupSessionId)!! + } + + val closedGroupMembers = remember(closedGroupInfo) { + storage.getMembers(groupSessionId).map { member -> + MemberViewModel( + memberName = member.name, + memberSessionId = member.sessionId, + currentUser = member.sessionId == currentUserId, + memberState = memberStateOf(member) + ) + } + } + + val name = closedGroup.name + val description = closedGroup.description + + EditGroupState( + EditGroupViewState( + groupName = name, + groupDescription = description, + memberStateList = closedGroupMembers, + admin = closedGroup.isUserAdmin + ) + ) { event -> + when (event) { + is EditGroupEvent.InviteContacts -> { + val sessionIds = event.contacts + storage.inviteClosedGroupMembers( + groupSessionId, + sessionIds.contacts.map(Contact::sessionID) + ) + Toast.makeText( + event.context, + "Inviting ${event.contacts.contacts.size}", + Toast.LENGTH_LONG + ).show() + } + is EditGroupEvent.ReInviteContact -> { + // do a buffer + JobQueue.shared.add(InviteContactsJob(groupSessionId, arrayOf(event.contactSessionId))) + } + is EditGroupEvent.PromoteContact -> { + // do a buffer + storage.promoteMember(groupSessionId, arrayOf(event.contactSessionId)) + } + is EditGroupEvent.RemoveContact -> { + storage.removeMember(groupSessionId, arrayOf(event.contactSessionId)) + } + is EditGroupEvent.ChangeName -> { + storage.setName(groupSessionId, event.newName) + } + } + } + } + + @AssistedFactory + interface Factory { + fun create(groupSessionId: String, contentResolver: ContentResolver): EditGroupViewModel + } + +} + +class EditGroupInviteViewModel @AssistedInject constructor( + @Assisted private val groupSessionId: String, + private val storage: StorageProtocol +): ViewModel() { + + val viewState = viewModelScope.launchMolecule(Immediate) { + + val currentUserId = rememberSaveable { + storage.getUserPublicKey()!! + } + + val contacts = remember { + storage.getAllContacts() + } + + val closedGroupMembers = remember { + storage.getMembers(groupSessionId).map { member -> + MemberViewModel( + memberName = member.name, + memberSessionId = member.sessionId, + currentUser = member.sessionId == currentUserId, + memberState = memberStateOf(member) + ) + } + } + + EditGroupInviteState( + EditGroupInviteViewState(closedGroupMembers, contacts) + ) + } + + @AssistedFactory + interface Factory { + fun create(groupSessionId: String): EditGroupInviteViewModel + } + +} + +@Composable +fun EditGroupView( + onBack: ()->Unit, + onInvite: ()->Unit, + onReinvite: (String)->Unit, + onPromote: (String)->Unit, + onRemove: (String)->Unit, + onEditName: ()->Unit, + viewState: EditGroupViewState, +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + scaffoldState = scaffoldState, + topBar = { + NavigationBar( + title = stringResource(id = R.string.activity_edit_closed_group_title), + onBack = onBack, + actionElement = {} + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + // Group name title + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = viewState.groupName, + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + if (viewState.admin) { + Icon( + painterResource(R.drawable.ic_baseline_edit_24), + null, + modifier = Modifier + .padding(8.dp) + .size(16.dp) + .clip(CircleShape) + .align(CenterVertically) + .clickable { onEditName() } + ) + } + } + // Description + + // Invite + if (viewState.admin) { + CellWithPaddingAndMargin(margin = 16.dp, padding = 16.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onInvite) + .padding(horizontal = 8.dp), + verticalAlignment = CenterVertically, + ) { + Icon(painterResource(id = R.drawable.ic_add_admins), contentDescription = null) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = stringResource(id = R.string.activity_edit_closed_group_add_members)) + } + } + } + // members header + Text( + text = stringResource(id = R.string.conversation_settings_group_members), + style = MaterialTheme.typography.subtitle2, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 32.dp) + ) + LazyColumn(modifier = Modifier) { + + items(viewState.memberStateList) { member -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp)) { + ContactPhoto(member.memberSessionId) + Column(modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .align(CenterVertically)) { + // Member's name + Text( + text = member.memberName ?: member.memberSessionId, + style = MemberNameStyle, + modifier = Modifier + .fillMaxWidth() + .padding(1.dp) + ) + if (member.memberState !in listOf(MemberState.Member, MemberState.Admin)) { + Text( + text = member.memberState.toString(), + modifier = Modifier + .fillMaxWidth() + .padding(1.dp) + ) + } + } + // Resend button + if (viewState.admin && member.memberState == MemberState.InviteFailed) { + TextButton( + onClick = { + onReinvite(member.memberSessionId) + }, + modifier = Modifier + .clip(CircleShape) + .background( + Color( + MaterialColors.getColor( + LocalContext.current, + R.attr.colorControlHighlight, + MaterialTheme.colors.onPrimary.toArgb() + ) + ) + ) + ) { + Text( + "Re-send", + color = MaterialTheme.colors.onPrimary + ) + } + } else if (viewState.admin && member.memberState == MemberState.Member) { + TextButton( + onClick = { + onPromote(member.memberSessionId) + }, + modifier = Modifier + .clip(CircleShape) + .background( + Color( + MaterialColors.getColor( + LocalContext.current, + R.attr.colorControlHighlight, + MaterialTheme.colors.onPrimary.toArgb() + ) + ) + ) + ) { + Text( + "Promote", + color = MaterialTheme.colors.onPrimary + ) + } + TextButton( + onClick = { + onRemove(member.memberSessionId) + }, + modifier = Modifier + .clip(CircleShape) + .background( + Color( + MaterialColors.getColor( + LocalContext.current, + R.attr.colorControlHighlight, + MaterialTheme.colors.onPrimary.toArgb() + ) + ) + ) + ) { + Icon(painter = painterResource(id = R.drawable.ic_baseline_close_24), contentDescription = null) + } + } + } + } + } + } + } +} + +data class EditGroupState( + val viewState: EditGroupViewState, + val eventSink: (EditGroupEvent) -> Unit +) + +data class EditGroupInviteState( + val viewState: EditGroupInviteViewState, +) + +data class MemberViewModel( + val memberName: String?, + val memberSessionId: String, + val memberState: MemberState, + val currentUser: Boolean, +) + +enum class MemberState { + InviteSent, + Inviting, // maybe just use these in view + InviteFailed, + PromotionSent, + Promoting, // maybe just use these in view + PromotionFailed, + Admin, + Member +} + +fun memberStateOf(member: GroupMember): MemberState = when { + member.inviteFailed -> MemberState.InviteFailed + member.invitePending -> MemberState.InviteSent + member.promotionFailed -> MemberState.PromotionFailed + member.promotionPending -> MemberState.PromotionSent + member.admin -> MemberState.Admin + else -> MemberState.Member +} + +data class EditGroupViewState( + val groupName: String, + val groupDescription: String?, + val memberStateList: List, + val admin: Boolean +) + +sealed class EditGroupEvent { + data class InviteContacts(val context: Context, + val contacts: ContactList): EditGroupEvent() + data class ReInviteContact(val contactSessionId: String): EditGroupEvent() + data class PromoteContact(val contactSessionId: String): EditGroupEvent() + data class RemoveContact(val contactSessionId: String): EditGroupEvent() + data class ChangeName(val newName: String): EditGroupEvent() +} + +data class EditGroupInviteViewState( + val currentMembers: List, + val allContacts: Set +) + +@Preview +@Composable +fun PreviewDialogChange(@PreviewParameter(ThemeResPreviewParameterProvider::class) styleRes: Int) { + + PreviewTheme(themeResId = styleRes) { + EditClosedGroupView { + + } + } + +} + +@Preview +@Composable +fun PreviewList() { + + PreviewTheme(themeResId = R.style.Classic_Dark) { + + val oneMember = MemberViewModel( + "Test User", + "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + MemberState.InviteSent, + false + ) + val twoMember = MemberViewModel( + "Test User 2", + "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235", + MemberState.InviteFailed, + false + ) + val threeMember = MemberViewModel( + "Test User 3", + "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236", + MemberState.Member, + false + ) + + val viewState = EditGroupViewState( + "Preview", + "This is a preview description", + listOf(oneMember, twoMember, threeMember), + true + ) + + EditGroupView( + onBack = {}, + onInvite = {}, + onReinvite = {}, + onPromote = {}, + onRemove = {}, + onEditName = {}, + viewState = viewState + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupNavGraph.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupNavGraph.kt new file mode 100644 index 000000000..b3cd75fee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupNavGraph.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups.compose + +import com.ramcosta.composedestinations.annotation.NavGraph + +@NavGraph +annotation class EditGroupNavGraph( + val start: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContacts.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContacts.kt new file mode 100644 index 000000000..cffb0b04a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContacts.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush.Companion.verticalGradient +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.thoughtcrime.securesms.home.search.getSearchName +import org.thoughtcrime.securesms.ui.CloseIcon +import org.thoughtcrime.securesms.ui.NavigationBar +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SelectContacts( + contactListState: Set, + onBack: ()->Unit, + onClose: (()->Unit)? = null, + onContactsSelected: (Set) -> Unit, + @StringRes okButtonResId: Int = R.string.ok +) { + + var queryFilter by remember { mutableStateOf("") } + + // May introduce more advanced filters + val filtered = if (queryFilter.isEmpty()) contactListState.toList() + else { + contactListState + .filter { contact -> + contact.getSearchName().lowercase() + .contains(queryFilter.lowercase()) + } + .toList() + } + + var selected by remember { + mutableStateOf(emptySet()) + } + + Column { + NavigationBar( + title = stringResource(id = R.string.activity_create_closed_group_select_contacts), + onBack = onBack, + actionElement = { + if (onClose != null) { + CloseIcon(onClose) + } + } + ) + + LazyColumn(modifier = Modifier.weight(1f)) { + stickyHeader { + // Search Bar + SearchBar(queryFilter, onValueChanged = { value -> queryFilter = value }) + } + + multiSelectMemberList( + contacts = filtered.toList(), + selectedContacts = selected, + onListUpdated = { selected = it }, + ) + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + .background( + verticalGradient( + 0f to Color.Transparent, + 0.2f to MaterialTheme.colors.primaryVariant, + ) + ) + ) { + OutlinedButton( + onClick = { onContactsSelected(selected) }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp).defaultMinSize(minWidth = 128.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.onPrimary), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.onPrimary, + ) + ) { + Text( + stringResource(id = okButtonResId) + ) + } + } + } + +} + +@Preview +@Composable +fun previewSelectContacts( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeRes: Int +) { + val empty = emptySet() + PreviewTheme(themeResId = themeRes) { + SelectContacts(contactListState = empty, onBack = { /*TODO*/ }, onContactsSelected = {}) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index f25545572..68255bffd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -145,7 +145,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address)) push(intent) } - is GlobalSearchAdapter.Model.GroupConversation -> { + is GlobalSearchAdapter.Model.LegacyGroupConversation -> { val groupAddress = Address.fromSerialized(model.groupRecord.encodedId) val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false)) if (threadId >= 0) { @@ -258,7 +258,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), globalSearchViewModel.result.collect { result -> val currentUserPublicKey = publicKey val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + - result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) } + result.threads.map { GlobalSearchAdapter.Model.LegacyGroupConversation(it) } val contactResults = contactAndGroupList.toMutableList() @@ -334,9 +334,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun setupMessageRequestsBanner() { - val messageRequestCount = threadDb.unapprovedConversationCount + val messageRequestCount = threadDb.unapprovedConversationList.use { it.count } // Set up message requests - if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) { + if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests() && messageRequestCount != homeAdapter.requestCount) { with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) { unreadCountTextView.text = messageRequestCount.toString() timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString( @@ -352,13 +352,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), if (hadHeader) homeAdapter.notifyItemChanged(0) else homeAdapter.notifyItemInserted(0) } - } else { + } else if (messageRequestCount == 0) { val hadHeader = homeAdapter.hasHeaderView() homeAdapter.header = null if (hadHeader) { homeAdapter.notifyItemRemoved(0) } } + homeAdapter.requestCount = messageRequestCount } private fun updateLegacyConfigView() { @@ -644,14 +645,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Cancel any outstanding jobs DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) // Send a leave group message if this is an active closed group - if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { + if (recipient.address.isLegacyClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { try { GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) ?.let { MessageSender.explicitLeave(it, false) } - } catch (_: IOException) { + } catch (e: IOException) { + Log.e("Loki", e) } } + if (recipient.address.isClosedGroup) { + TODO("Implement leaving / deleting a new closed group conversation") + } // Delete the conversation val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) if (v2OpenGroup != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index eaf242aae..b47f77343 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -38,6 +38,7 @@ class HomeAdapter( } fun hasHeaderView(): Boolean = header != null + var requestCount = 0 private val headerCount: Int get() = if (header == null) 0 else 1 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 7cf953be2..ffa7fba3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -9,9 +9,10 @@ import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding +import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.mms.GlideApp +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.search.model.MessageResult import java.security.InvalidParameterException import org.session.libsession.messaging.contacts.Contact as ContactModel @@ -98,7 +99,7 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi fun bind(query: String, model: Model) { binding.searchResultProfilePicture.recycle() when (model) { - is Model.GroupConversation -> bindModel(query, model) + is Model.LegacyGroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) is Model.Message -> bindModel(query, model) is Model.SavedMessages -> bindModel(model) @@ -119,7 +120,8 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi data class Header(@StringRes val title: Int) : Model() data class SavedMessages(val currentUserPublicKey: String): Model() data class Contact(val contact: ContactModel) : Model() - data class GroupConversation(val groupRecord: GroupRecord) : Model() + data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model() + data class ClosedGroupConversation(val sessionId: SessionId) data class Message(val messageResult: MessageResult, val unread: Int) : Model() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 5371bb71c..09b972146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -11,7 +11,7 @@ import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView -import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages @@ -65,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { binding.searchResultSubtitle.isVisible = true binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() } - is GroupConversation -> { + is LegacyGroupConversation -> { binding.searchResultTitle.text = getHighlight( query, model.groupRecord.title @@ -86,10 +86,10 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query) } -fun ContentView.bindModel(query: String?, model: GroupConversation) { +fun ContentView.bindModel(query: String?, model: LegacyGroupConversation) { binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false - binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup + binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) binding.searchResultProfilePicture.update(threadRecipient) @@ -102,7 +102,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { val address = it.address.serialize() it.name ?: "${address.take(4)}...${address.takeLast(4)}" } - if (model.groupRecord.isClosedGroup) { + if (model.groupRecord.isLegacyClosedGroup) { binding.searchResultSubtitle.text = getHighlight(query, membersString) } } @@ -132,11 +132,6 @@ fun ContentView.bindModel(query: String?, model: Message) { binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultTimestamp.isVisible = true -// val hasUnreads = model.unread > 0 -// binding.unreadCountIndicator.isVisible = hasUnreads -// if (hasUnreads) { -// binding.unreadCountTextView.text = model.unread.toString() -// } binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index 10142cc8f..ae2d82577 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messagerequests import android.content.Context import android.content.res.ColorStateList import android.database.Cursor +import android.os.Build import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.view.ContextThemeWrapper @@ -61,10 +62,14 @@ class MessageRequestsAdapter( val item = popupMenu.menu.getItem(i) val s = SpannableString(item.title) s.setSpan(ForegroundColorSpan(context.getColor(R.color.destructive)), 0, s.length, 0) - item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive)) + } item.title = s } - popupMenu.setForceShowIcon(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + popupMenu.setForceShowIcon(true) + } popupMenu.show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index e583fb0ca..b9adac4f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -7,8 +7,8 @@ import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters @@ -18,7 +18,7 @@ import nl.komponents.kovenant.functional.bind import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.TextSecurePreferences @@ -121,7 +121,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor // Closed groups if (requestTargets.contains(Targets.CLOSED_GROUPS)) { - val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared + val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared val storage = MessagingModuleConfiguration.shared.storage val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 2c70bff63..b26cf8743 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -42,7 +42,6 @@ import com.goterl.lazysodium.utils.KeyPair; import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.messaging.utilities.SessionId; import org.session.libsession.messaging.utilities.SodiumUtilities; import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; @@ -52,6 +51,7 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.IdPrefix; import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.SessionId; import org.session.libsignal.utilities.Util; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contacts.ContactUtil; @@ -555,7 +555,7 @@ public class DefaultMessageNotifier implements MessageNotifier { if (openGroup != null && edKeyPair != null) { KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); if (blindedKeyPair != null) { - return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); + return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).hexString(); } } return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt index b0954f232..8d068690d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt @@ -73,7 +73,7 @@ class PushRegistry @Inject constructor( token: String, publicKey: String, userEd25519Key: KeyPair, - namespaces: List = listOf(Namespace.DEFAULT) + namespaces: List = listOf(Namespace.DEFAULT()) ): Promise<*, Exception> { Log.d(TAG, "register() called") diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 4bef45ff9..d05eb2a0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -53,7 +53,7 @@ class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) val requestParameters = SubscriptionRequest( pubkey = publicKey, session_ed25519 = userEd25519Key.publicKey.asHexString, - namespaces = listOf(Namespace.DEFAULT), + namespaces = listOf(Namespace.DEFAULT()), data = true, // only permit data subscription for now (?) service = device.service, sig_ts = timestamp, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index dbe09668c..85d97d8b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.Storage @@ -28,7 +29,7 @@ import org.thoughtcrime.securesms.util.adapter.SelectableItem import javax.inject.Inject @HiltViewModel -class BlockedContactsViewModel @Inject constructor(private val storage: Storage): ViewModel() { +class BlockedContactsViewModel @Inject constructor(private val storage: StorageProtocol): ViewModel() { private val executor = viewModelScope + SupervisorJob() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 37a54a4af..b6e2f2f1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -104,7 +104,8 @@ class ClearAllDataDialog : DialogFragment() { if (!deleteNetworkMessages) { try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() + // TODO: maybe convert this to a blocking config job + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()) } catch (e: Exception) { Log.e("Loki", "Failed to force sync", e) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 1c05e68bd..b656eb620 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -9,11 +9,10 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import org.session.libsession.messaging.utilities.SessionId; +import org.session.libsignal.utilities.SessionId; import org.thoughtcrime.securesms.components.ProfilePictureView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; -import org.thoughtcrime.securesms.mms.GlideApp; import java.util.Collections; import java.util.List; diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 2c0b39d69..940468b5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -77,7 +77,7 @@ interface ConversationRepository { suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf - fun declineMessageRequest(threadId: Long) + fun declineMessageRequest(threadId: Long, recipient: Recipient) fun hasReceived(threadId: Long): Boolean @@ -286,8 +286,7 @@ class DefaultConversationRepository @Inject constructor( } override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf { - sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - storage.deleteConversation(thread.threadId) + declineMessageRequest(thread.threadId, thread.recipient) return ResultOf.Success(Unit) } @@ -306,19 +305,27 @@ class DefaultConversationRepository @Inject constructor( override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> storage.setRecipientApproved(recipient, true) - val message = MessageRequestResponse(true) - MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) - .success { - threadDb.setHasSent(threadId, true) - continuation.resume(ResultOf.Success(Unit)) - }.fail { error -> - continuation.resumeWithException(error) - } + if (recipient.isClosedGroupRecipient) { + storage.respondToClosedGroupInvitation(recipient, true) + } else { + val message = MessageRequestResponse(true) + MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) + .success { + threadDb.setHasSent(threadId, true) + continuation.resume(ResultOf.Success(Unit)) + }.fail { error -> + continuation.resumeWithException(error) + } + } } - override fun declineMessageRequest(threadId: Long) { + override fun declineMessageRequest(threadId: Long, recipient: Recipient) { sessionJobDb.cancelPendingMessageSendJobs(threadId) - storage.deleteConversation(threadId) + if (recipient.isClosedGroupRecipient) { + storage.respondToClosedGroupInvitation(recipient, false) + } else { + storage.deleteConversation(threadId) + } } override fun hasReceived(threadId: Long): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index 8b1975865..f211460b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -5,11 +5,11 @@ import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob -import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.SessionId import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1724bde8a..0183b08b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -2,18 +2,28 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.ButtonColors import androidx.compose.material.Card import androidx.compose.material.Colors @@ -22,15 +32,24 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.google.accompanist.pager.HorizontalPagerIndicator import kotlinx.coroutines.launch @@ -95,7 +114,7 @@ fun CellWithPaddingAndMargin( } } -private val Colors.cellColor: Color +val Colors.cellColor: Color @Composable get() = LocalExtraColors.current.settingsBackground @@ -180,3 +199,160 @@ fun RowScope.Avatar(recipient: Recipient) { ) } } + +@Composable +fun EditableAvatar( + // TODO: add attachment-based state for current view rendering? + modifier: Modifier = Modifier +) { + Box(modifier = modifier + .size(110.dp) + .padding(15.dp) + ) { + Image( + painter = painterResource(id = R.drawable.avatar_placeholder), + contentDescription = stringResource( + id = R.string.arrays__default + ), + Modifier + .fillMaxSize() + .background(MaterialTheme.colors.cellColor, shape = CircleShape) + .padding(16.dp) + ) + Image( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null, + Modifier + .align(Alignment.BottomEnd) + .size(24.dp) + .background(colorDestructive, shape = CircleShape) + .padding(6.dp) + ) + } +} + +@Composable +fun SearchBar( + query: String, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .background(MaterialTheme.colors.primaryVariant, RoundedCornerShape(100)) + ) { + Image( + painterResource(id = R.drawable.ic_search_24), + contentDescription = null, + colorFilter = ColorFilter.tint( + MaterialTheme.colors.onPrimary + ), + modifier = Modifier.size(20.dp) + ) + + BasicTextField( + singleLine = true, +// label = { Text(text = stringResource(id = R.string.search_contacts_hint),modifier=Modifier.padding(0.dp)) }, + value = query, + onValueChange = onValueChanged, + modifier = Modifier + .padding(start = 8.dp) + .padding(4.dp) + .weight(1f), + ) + } +} + +@Composable +fun NavigationBar( + title: String, + titleAlignment: Alignment = Alignment.Center, + onBack: (() -> Unit)? = null, + actionElement: (@Composable BoxScope.() -> Unit)? = null +) { + Row( + Modifier + .fillMaxWidth() + .height(64.dp)) { + // Optional back button, layout should still take up space + Box(modifier = Modifier + .fillMaxHeight() + .aspectRatio(1.0f, true) + .padding(16.dp) + ) { + if (onBack != null) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_left_24), + contentDescription = stringResource( + id = R.string.new_conversation_dialog_back_button_content_description + ), + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 16.dp), + ) { onBack() } + .align(Alignment.Center) + ) + } + } + //Main title + Box(modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(8.dp)) { + Text( + text = title, + Modifier.align(titleAlignment), + overflow = TextOverflow.Ellipsis, + fontSize = 26.sp, + fontWeight = FontWeight.Bold + ) + } + // Optional action + if (actionElement != null) { + Box(modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterVertically) + .aspectRatio(1.0f, true), + contentAlignment = Alignment.Center + ) { + actionElement(this) + } + } + } +} + +@Composable +fun BoxScope.CloseIcon(onClose: ()->Unit) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_close_24), + contentDescription = stringResource( + id = R.string.new_conversation_dialog_close_button_content_description + ), + Modifier + .clickable { onClose() } + .align(Alignment.Center) + .padding(16.dp) + ) +} + +@Composable +@Preview +fun PreviewNavigationBar(@PreviewParameter(provider = ThemeResPreviewParameterProvider::class) themeResId: Int) { + PreviewTheme(themeResId = themeResId) { + NavigationBar(title = "Create Group", onBack = {}, actionElement = { + CloseIcon {} + }) + } +} + +@Composable +@Preview +fun PreviewSearchBar(@PreviewParameter(provider = ThemeResPreviewParameterProvider::class) themeResId: Int) { + PreviewTheme(themeResId = themeResId) { + SearchBar("", {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt index 64bbd21d8..95f754557 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -18,10 +18,12 @@ import com.google.android.material.color.MaterialColors import network.loki.messenger.R val LocalExtraColors = staticCompositionLocalOf { error("No Custom Attribute value provided") } +val LocalPreviewMode = staticCompositionLocalOf { false } data class ExtraColors( val settingsBackground: Color, + val destructive: Color ) /** @@ -34,6 +36,7 @@ fun AppTheme( val extraColors = LocalContext.current.run { ExtraColors( settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + destructive = Color(getColor(R.color.destructive)), ) } @@ -56,7 +59,8 @@ fun PreviewTheme( content: @Composable () -> Unit ) { CompositionLocalProvider( - LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) + LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId), + LocalPreviewMode provides true ) { AppTheme { Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt new file mode 100644 index 000000000..398bcbcc0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.util + +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment + +private const val ZERO_SIZE = "0.00" +private const val KILO_SIZE = 1024f +private const val MB_SUFFIX = "MB" +private const val KB_SUFFIX = "KB" + +fun Attachment.displaySize(): String { + + val kbSize = size / KILO_SIZE + val needsMb = kbSize > KILO_SIZE + val sizeText = "%.2f".format(if (needsMb) kbSize / KILO_SIZE else kbSize) + val displaySize = when { + sizeText == ZERO_SIZE -> "0.01" + sizeText.endsWith(".00") -> sizeText.takeWhile { it != '.' } + else -> sizeText + } + return "$displaySize${if (needsMb) MB_SUFFIX else KB_SUFFIX}" +} + +fun JobQueue.createAndStartAttachmentDownload(attachment: DatabaseAttachment) { + val attachmentId = attachment.attachmentId.rowId + if (attachment.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + // start download + add(AttachmentDownloadJob(attachmentId, attachment.mmsId)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index 297014d86..bd2358c8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -11,7 +11,6 @@ import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic -import nl.komponents.kovenant.Promise import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.ConfigurationSyncJob import org.session.libsession.messaging.jobs.JobQueue @@ -26,28 +25,48 @@ import org.session.libsession.utilities.WindowDebouncer import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.SessionId import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.Timer +import java.util.concurrent.ConcurrentLinkedDeque object ConfigurationMessageUtilities { private val debouncer = WindowDebouncer(3000, Timer()) + private val destinationUpdater = Any() + private val pendingDestinations = ConcurrentLinkedDeque() - private fun scheduleConfigSync(userPublicKey: String) { + private fun scheduleConfigSync(destination: Destination) { + synchronized(destinationUpdater) { + pendingDestinations.add(destination) + } debouncer.publish { // don't schedule job if we already have one val storage = MessagingModuleConfiguration.shared.storage - val ourDestination = Destination.Contact(userPublicKey) - val currentStorageJob = storage.getConfigSyncJob(ourDestination) - if (currentStorageJob != null) { - (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) - return@publish + val configFactory = MessagingModuleConfiguration.shared.configFactory + val destinations = synchronized(destinationUpdater) { + val objects = pendingDestinations.toList() + pendingDestinations.clear() + objects + } + destinations.forEach { destination -> + if (destination is Destination.ClosedGroup) { + // ensure we have the appropriate admin keys, skip this destination otherwise + val group = configFactory.userGroups?.getClosedGroup(destination.publicKey) ?: return@forEach + if (group.adminKey.isEmpty()) return@forEach Log.w("ConfigurationSync", "Trying to schedule config sync for group we aren't an admin of") + } + val currentStorageJob = storage.getConfigSyncJob(destination) + if (currentStorageJob != null) { + (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) + return@publish + } + val newConfigSync = ConfigurationSyncJob(destination) + JobQueue.shared.add(newConfigSync) } - val newConfigSync = ConfigurationSyncJob(ourDestination) - JobQueue.shared.add(newConfigSync) } } @@ -58,7 +77,7 @@ object ConfigurationMessageUtilities { val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) val currentTime = SnodeAPI.nowWithOffset if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { - scheduleConfigSync(userPublicKey) + scheduleConfigSync(Destination.Contact(userPublicKey)) return } val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) @@ -82,34 +101,21 @@ object ConfigurationMessageUtilities { TextSecurePreferences.setLastConfigurationSyncTime(context, now) } - fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { + fun forceSyncConfigurationNowIfNeeded(destination: Destination) { + scheduleConfigSync(destination) + } + + + fun forceSyncConfigurationNowIfNeeded(context: Context) { // add if check here to schedule new config job process and return early - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.e("Loki", NullPointerException("User Public Key is null")) val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) val currentTime = SnodeAPI.nowWithOffset if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { // schedule job if none exist // don't schedule job if we already have one - scheduleConfigSync(userPublicKey) - return Promise.ofSuccess(Unit) + scheduleConfigSync(Destination.Contact(userPublicKey)) } - val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> - !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() - }.map { recipient -> - ConfigurationMessage.Contact( - publicKey = recipient.address.serialize(), - name = recipient.name!!, - profilePicture = recipient.profileAvatar, - profileKey = recipient.profileKey, - isApproved = recipient.isApproved, - isBlocked = recipient.isBlocked, - didApproveMe = recipient.hasApprovedMe() - ) - } - val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) - val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true) - TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) - return promise } private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes @@ -199,6 +205,11 @@ object ConfigurationMessageUtilities { convoConfig.getOrConstructCommunity(base, room, pubKey) } recipient.isClosedGroupRecipient -> { + // It's probably safe to assume there will never be a case where new closed groups will ever be there before a dump is created... + // but just in case... + convoConfig.getOrConstructClosedGroup(recipient.address.serialize()) + } + recipient.isLegacyClosedGroupRecipient -> { val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) convoConfig.getOrConstructLegacyGroup(groupPublicKey) } @@ -241,7 +252,7 @@ object ConfigurationMessageUtilities { } val allLgc = storage.getAllGroups(includeInactive = false).filter { - it.isClosedGroup && it.isActive && it.members.size > 1 + it.isLegacyClosedGroup && it.isActive && it.members.size > 1 }.mapNotNull { group -> val groupAddress = Address.fromSerialized(group.encodedId) val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() @@ -252,7 +263,7 @@ object ConfigurationMessageUtilities { val admins = group.admins.map { it.serialize() to true }.toMap() val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() GroupInfo.LegacyGroupInfo( - sessionId = groupPublicKey, + sessionId = SessionId.from(groupPublicKey), name = group.title, members = admins + members, priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, @@ -273,13 +284,13 @@ object ConfigurationMessageUtilities { @JvmField val DELETE_INACTIVE_GROUPS: String = """ - DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); - DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%'); """.trimIndent() @JvmField val DELETE_INACTIVE_ONE_TO_ONES: String = """ - DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; """.trimIndent() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt index b15d82a33..a259df2e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -13,6 +13,8 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { return getOneToOne(recipient.address.serialize())?.unread == true } else if (recipient.isClosedGroupRecipient) { + return getClosedGroup(recipient.address.serialize())?.unread == true + } else if (recipient.isLegacyClosedGroupRecipient) { return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true } else if (recipient.isOpenGroupRecipient) { val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false diff --git a/app/src/main/res/drawable/avatar_placeholder.xml b/app/src/main/res/drawable/avatar_placeholder.xml new file mode 100644 index 000000000..0e0f28541 --- /dev/null +++ b/app/src/main/res/drawable/avatar_placeholder.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/debug_border.xml b/app/src/main/res/drawable/debug_border.xml new file mode 100644 index 000000000..e0a28b77e --- /dev/null +++ b/app/src/main/res/drawable/debug_border.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_admins.xml b/app/src/main/res/drawable/ic_add_admins.xml new file mode 100644 index 000000000..b0b326ca3 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_admins.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_all_media.xml b/app/src/main/res/drawable/ic_all_media.xml new file mode 100644 index 000000000..a9b3bdfdd --- /dev/null +++ b/app/src/main/res/drawable/ic_all_media.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_clear_messages.xml b/app/src/main/res/drawable/ic_clear_messages.xml new file mode 100644 index 000000000..e79703910 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_messages.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..f3db9cc27 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_disappearing_messages.xml b/app/src/main/res/drawable/ic_disappearing_messages.xml new file mode 100644 index 000000000..1e2de4e75 --- /dev/null +++ b/app/src/main/res/drawable/ic_disappearing_messages.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_edit_group.xml b/app/src/main/res/drawable/ic_edit_group.xml new file mode 100644 index 000000000..f647fea3e --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_group.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_leave_group.xml b/app/src/main/res/drawable/ic_leave_group.xml new file mode 100644 index 000000000..a6a235aeb --- /dev/null +++ b/app/src/main/res/drawable/ic_leave_group.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_media.xml b/app/src/main/res/drawable/ic_media.xml new file mode 100644 index 000000000..16b35db22 --- /dev/null +++ b/app/src/main/res/drawable/ic_media.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_notification_settings.xml b/app/src/main/res/drawable/ic_notification_settings.xml new file mode 100644 index 000000000..e3dea6f2a --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pin_conversation.xml b/app/src/main/res/drawable/ic_pin_conversation.xml new file mode 100644 index 000000000..b2ff304b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_conversation.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_search_conversation.xml b/app/src/main/res/drawable/ic_search_conversation.xml new file mode 100644 index 000000000..bd9eaad36 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_conversation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_conversation_notification_settings.xml b/app/src/main/res/layout/activity_conversation_notification_settings.xml new file mode 100644 index 000000000..5482601ca --- /dev/null +++ b/app/src/main/res/layout/activity_conversation_notification_settings.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_settings.xml b/app/src/main/res/layout/activity_conversation_settings.xml new file mode 100644 index 000000000..c12a9857e --- /dev/null +++ b/app/src/main/res/layout/activity_conversation_settings.xml @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_edit_closed_group.xml b/app/src/main/res/layout/activity_edit_closed_group.xml index e8a892f18..298bdfcbd 100644 --- a/app/src/main/res/layout/activity_edit_closed_group.xml +++ b/app/src/main/res/layout/activity_edit_closed_group.xml @@ -4,7 +4,7 @@ android:layout_height="match_parent" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" - tools:context="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"> + tools:context="org.thoughtcrime.securesms.groups.EditLegacyClosedGroupActivity"> + + + + + + + + +