diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 00000000000..e7f705722ff --- /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/.gitignore b/.gitignore index be928b39335..bf9ef62a972 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ project.properties .project .settings +.kotlin bin/ gen/ .idea/ diff --git a/app/build.gradle b/app/build.gradle index 37673102a9a..52c06e8298f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,18 @@ plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'org.jetbrains.kotlin.plugin.compose' id 'com.google.devtools.ksp' id 'com.google.dagger.hilt.android' + id 'kotlin-parcelize' + id 'kotlinx-serialization' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' apply plugin: 'witness' -apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlinx-serialization' -configurations.forEach { - it.exclude module: "commons-logging" +configurations.configureEach { + exclude module: "commons-logging" } def canonicalVersionCode = 384 @@ -40,12 +42,12 @@ android { useLibrary 'org.apache.http.legacy' compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } packagingOptions { @@ -54,6 +56,7 @@ android { } } + splits { abi { enable !project.hasProperty('huawei') // huawei builds do not need the split variants @@ -64,7 +67,8 @@ android { } buildFeatures { - compose true + viewBinding true + buildConfig true } composeOptions { @@ -198,11 +202,11 @@ android { } } - buildFeatures { - viewBinding true - } - def huaweiEnabled = project.properties['huawei'] != null + lint { + abortOnError true + baseline file('lint-baseline.xml') + } applicationVariants.configureEach { variant -> if (variant.flavorName == 'huawei') { @@ -261,6 +265,7 @@ dependencies { ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") ksp("com.github.bumptech.glide:ksp:$glideVersion") + implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion") implementation("com.google.dagger:hilt-android:$daggerHiltVersion") implementation "androidx.appcompat:appcompat:$appcompatVersion" @@ -340,7 +345,6 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.phrase:phrase:$phraseVersion" implementation 'app.cash.copper:copper-flow:1.0.0' - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" @@ -363,7 +367,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' @@ -382,6 +385,8 @@ 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.12.2' @@ -405,6 +410,11 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4-android" debugImplementation "androidx.compose.ui:ui-test-manifest" + // Navigation + implementation "androidx.navigation:navigation-fragment-ktx:$navVersion" + implementation "androidx.navigation:navigation-ui-ktx:$navVersion" + implementation "androidx.navigation:navigation-compose:$navVersion" + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" implementation "com.google.accompanist:accompanist-permissions:0.36.0" implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha" diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index 023120e1a8e..be464ad75a9 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -3,5 +3,8 @@ + + + \ 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 00000000000..c4b81d8148b --- /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.ViewState +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + +@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 { + PreviewTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { backPressed = true }, + onClose = { closePressed = true }, + onContactItemClicked = {}, + 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 { + PreviewTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { backPressed = true }, + onClose = { closePressed = true }, + updateState = {}, + onContactItemClicked = {} + ) + } + } + 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 { + PreviewTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { backPressed = true }, + onClose = {}, + onContactItemClicked = {}, + 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 { + PreviewTheme { + CreateGroup( + viewState = ViewState.DEFAULT, + onBack = { }, + onClose = { closePressed = true }, + onContactItemClicked = {}, + 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/EditGroupTests.kt b/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt new file mode 100644 index 00000000000..e0799b0a3a0 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt @@ -0,0 +1,257 @@ +package network.loki.messenger + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasContentDescriptionExactly +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.groups.compose.EditGroup +import org.thoughtcrime.securesms.groups.EditGroupViewState +import org.thoughtcrime.securesms.groups.MemberState +import org.thoughtcrime.securesms.groups.MemberViewModel +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + +@RunWith(AndroidJUnit4::class) +@SmallTest +class EditGroupTests { + + @get:Rule + val composeTest = createComposeRule() + + 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 fourMember = MemberViewModel( + "Test User 4", + "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1237", + MemberState.Admin, + false + ) + + @Test + fun testDisplaysNameAndDesc() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + // Accessibility IDs + val nameDesc = application.getString(R.string.AccessibilityId_group_name) + val descriptionDesc = application.getString(R.string.AccessibilityId_group_description) + + with (composeTest) { + val state = EditGroupViewState( + "TestGroup", + "TestDesc", + emptyList(), + false + ) + + setContent { + PreviewTheme { + EditGroup( + onBackClick = {}, + onAddMemberClick = {}, + onResendInviteClick = {}, + onPromoteClick = {}, + onRemoveClick = {}, + onEditName = {}, + onMemberSelected = {}, + viewState = state + ) + } + } + onNode(hasContentDescriptionExactly(nameDesc)).assertTextEquals("TestGroup") + onNode(hasContentDescriptionExactly(descriptionDesc)).assertTextEquals("TestDesc") + } + } + + @Test + fun testDisplaysReinviteProperly() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + + // Accessibility IDs + val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member) + val promoteDesc = application.getString(R.string.AccessibilityId_promote_member) + + var reinvited = false + + with (composeTest) { + + val state = EditGroupViewState( + "TestGroup", + "TestDesc", + listOf( + twoMember + ), + // reinvite only shows for admin users + true + ) + + setContent { + PreviewTheme { + EditGroup( + onBackClick = {}, + onAddMemberClick = {}, + onResendInviteClick = { reinvited = true }, + onPromoteClick = {}, + onRemoveClick = {}, + onEditName = {}, + onMemberSelected = {}, + viewState = state + ) + } + } + onNodeWithContentDescription(reinviteDesc).assertIsDisplayed().performClick() + onNodeWithContentDescription(promoteDesc).assertDoesNotExist() + assertThat(reinvited, equalTo(true)) + } + } + + @Test + fun testDisplaysRegularMemberProperly() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + + // Accessibility IDs + val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member) + val promoteDesc = application.getString(R.string.AccessibilityId_promote_member) + + var promoted = false + + with (composeTest) { + + val state = EditGroupViewState( + "TestGroup", + "TestDesc", + listOf( + threeMember + ), + // reinvite only shows for admin users + true + ) + + setContent { + PreviewTheme { + EditGroup( + onBackClick = {}, + onAddMemberClick = {}, + onResendInviteClick = {}, + onPromoteClick = { promoted = true }, + onRemoveClick = {}, + onEditName = {}, + onMemberSelected = {}, + viewState = state + ) + } + } + onNodeWithContentDescription(reinviteDesc).assertDoesNotExist() + onNodeWithContentDescription(promoteDesc).assertIsDisplayed().performClick() + assertThat(promoted, equalTo(true)) + } + } + + @Test + fun testDisplaysAdminProperly() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + + // Accessibility IDs + val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member) + val promoteDesc = application.getString(R.string.AccessibilityId_promote_member) + + with (composeTest) { + + val state = EditGroupViewState( + "TestGroup", + "TestDesc", + listOf( + fourMember + ), + // reinvite only shows for admin users + true + ) + + setContent { + PreviewTheme { + EditGroup( + onBackClick = {}, + onAddMemberClick = {}, + onResendInviteClick = {}, + onPromoteClick = {}, + onRemoveClick = {}, + onEditName = {}, + onMemberSelected = {}, + viewState = state + ) + } + } + onNodeWithContentDescription(reinviteDesc).assertDoesNotExist() + onNodeWithContentDescription(promoteDesc).assertDoesNotExist() + } + } + + @Test + fun testDisplaysPendingInviteProperly() { + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + + // Accessibility IDs + val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member) + val promoteDesc = application.getString(R.string.AccessibilityId_promote_member) + val stateDesc = application.getString(R.string.AccessibilityId_member_state) + val memberDesc = application.getString(R.string.AccessibilityId_contact) + + with (composeTest) { + + val state = EditGroupViewState( + "TestGroup", + "TestDesc", + listOf( + oneMember + ), + // reinvite only shows for admin users + true + ) + + setContent { + PreviewTheme { + EditGroup( + onBackClick = {}, + onAddMemberClick = {}, + onResendInviteClick = {}, + onPromoteClick = {}, + onRemoveClick = {}, + onEditName = {}, + onMemberSelected = {}, + viewState = state + ) + } + } + onNodeWithContentDescription(reinviteDesc).assertDoesNotExist() + onNodeWithContentDescription(promoteDesc).assertDoesNotExist() + onNodeWithContentDescription(stateDesc, useUnmergedTree = true).assertTextEquals("InviteSent") + onNodeWithContentDescription(memberDesc, useUnmergedTree = true).assertTextEquals("Test User") + } + } + +} \ 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 43b347ba42b..ddb999ab56d 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -1,12 +1,11 @@ package network.loki.messenger -import android.Manifest import android.app.Instrumentation import android.view.View +import android.content.ClipboardManager +import android.content.Context 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 @@ -16,14 +15,15 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId 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 network.loki.messenger.util.sendMessage +import network.loki.messenger.util.waitFor import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.adevinta.android.barista.interaction.PermissionGranter import com.bumptech.glide.Glide import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable -import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not import org.junit.After @@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.home.HomeActivity */ @RunWith(AndroidJUnit4::class) -@LargeTest +@SmallTest class HomeActivityTests { @get:Rule @@ -108,6 +108,7 @@ class HomeActivityTests { onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) // new chat + Thread.sleep(500) onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.copyButton)).perform(ViewActions.click()) val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -147,11 +148,13 @@ class HomeActivityTests { setupLoggedInState() goToMyChat() TextSecurePreferences.setLinkPreviewsEnabled(context, 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 @@ -161,7 +164,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()) @@ -175,21 +180,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 54470569e19..39e9526aecd 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -11,6 +11,10 @@ import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Conversation 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.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.MatcherAssert.assertThat @@ -31,32 +35,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 randomAccountId() = 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.newInstance(key) @@ -98,11 +85,10 @@ 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 = randomAccountId() + val newContactId = randomSessionId() val singleContact = Contact( id = newContactId, approved = true, @@ -111,10 +97,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 }, any()) - verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) + verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) } @Test @@ -123,7 +109,7 @@ class LibSessionTests { val storageSpy = spy(app.storage) app.storage = storageSpy - val randomRecipient = randomAccountId() + val randomRecipient = randomSessionId() val newContact = Contact( id = randomRecipient, approved = true, @@ -158,7 +144,7 @@ class LibSessionTests { app.storage = storageSpy // Initial state - val randomRecipient = randomAccountId() + val randomRecipient = randomSessionId() val currentContact = Contact( id = randomRecipient, approved = true, 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 00000000000..e7a3ce107cf --- /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 00000000000..7b02efad68c --- /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/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt index 26a484df16f..9527e3462b1 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt @@ -4,6 +4,7 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.session.libsession.messaging.notifications.TokenFetcher @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt index 0a5c14fd421..a246acbac9c 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt @@ -4,18 +4,16 @@ import android.os.Bundle import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage import dagger.hilt.android.AndroidEntryPoint -import org.json.JSONException -import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import java.lang.Exception import javax.inject.Inject private val TAG = HuaweiPushService::class.java.simpleName @AndroidEntryPoint class HuaweiPushService: HmsMessageService() { - @Inject lateinit var pushRegistry: PushRegistry + @Inject lateinit var tokenFetcher: TokenFetcher @Inject lateinit var pushReceiver: PushReceiver override fun onMessageReceived(message: RemoteMessage?) { @@ -25,16 +23,15 @@ class HuaweiPushService: HmsMessageService() { } override fun onNewToken(token: String?) { - pushRegistry.register(token) + if (token != null) { + tokenFetcher.onNewToken(token) + } } override fun onNewToken(token: String?, bundle: Bundle?) { Log.d(TAG, "New HCM token: $token.") - pushRegistry.register(token) - } - - override fun onDeletedMessages() { - Log.d(TAG, "onDeletedMessages") - pushRegistry.refresh(false) + if (token != null) { + tokenFetcher.onNewToken(token) + } } } diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt index f0ce294596a..2c32cb5a294 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt @@ -2,14 +2,13 @@ package org.thoughtcrime.securesms.notifications import android.content.Context import com.huawei.hms.aaid.HmsInstanceId -import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.session.libsignal.utilities.Log +import org.session.libsession.messaging.notifications.TokenFetcher import javax.inject.Inject import javax.inject.Singleton @@ -19,13 +18,20 @@ private const val TOKEN_SCOPE = "HCM" @Singleton class HuaweiTokenFetcher @Inject constructor( @ApplicationContext private val context: Context, - private val pushRegistry: Lazy, ): TokenFetcher { - override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run { - // https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370 - // getToken may return an empty string, if so HuaweiPushService#onNewToken will be called. - withContext(Dispatchers.IO) { - getToken(APP_ID, TOKEN_SCOPE) + override val token = MutableStateFlow(null) + + override fun onNewToken(token: String) { + this.token.value = token + } + + init { + GlobalScope.launch { + val instanceId = HmsInstanceId.getInstance(context) + withContext(Dispatchers.Default) { + instanceId.getToken(APP_ID, TOKEN_SCOPE) + } } + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eac4ef33001..eeead1c3cef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -154,7 +154,13 @@ android:label="@string/conversationsBlockedContacts" /> + + - messageNotifierLazy; + @Inject LokiAPIDatabase apiDB; + @Inject EmojiSearchDatabase emojiSearchDb; private volatile boolean isAppVisible; @@ -162,14 +180,21 @@ public static ApplicationContext getInstance(Context context) { return (ApplicationContext) context.getApplicationContext(); } + @Deprecated(message = "Use proper DI to inject this component") public TextSecurePreferences getPrefs() { return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs(); } + @Deprecated(message = "Use proper DI to inject this component") public DatabaseComponent getDatabaseComponent() { return EntryPoints.get(getApplicationContext(), DatabaseComponent.class); } + @Deprecated(message = "Use proper DI to inject this component") + public MessageNotifier getMessageNotifier() { + return messageNotifierLazy.get(); + } + public Handler getConversationListNotificationHandler() { if (this.conversationListHandlerThread == null) { conversationListHandlerThread = new HandlerThread("ConversationListHandler"); @@ -193,12 +218,12 @@ public PersistentLogger getPersistentLogger() { } @Override - public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) { - // forward to the config factory / storage ig - if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { - textSecurePreferences.setConfigurationMessageSynced(true); + public void toast(@StringRes int stringRes, int toastLength, @NonNull Map parameters) { + Phrase builder = Phrase.from(this, stringRes); + for (Map.Entry entry : parameters.entrySet()) { + builder.put(entry.getKey(), entry.getValue()); } - storage.notifyConfigUpdates(forConfigObject, messageTimestamp); + Toast.makeText(getApplicationContext(), builder.format(), toastLength).show(); } @Override @@ -214,9 +239,12 @@ public void onCreate() { storage, device, messageDataProvider, - ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), configFactory, - lastSentTimestampCache + lastSentTimestampCache, + this, + tokenFetcher, + groupManagerV2, + snodeClock ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); @@ -227,18 +255,11 @@ public void onCreate() { NotificationChannels.create(this); ProcessLifecycleOwner.get().getLifecycle().addObserver(this); AppContext.INSTANCE.configureKovenant(); - messageNotifier = new OptimizedMessageNotifier(new DefaultMessageNotifier()); broadcaster = new Broadcaster(this); - LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase(); boolean useTestNet = textSecurePreferences.getEnvironment() == Environment.TEST_NET; SnodeModule.Companion.configure(apiDB, broadcaster, useTestNet); - initializeExpiringMessageManager(); - initializeTypingStatusRepository(); - initializeTypingStatusSender(); - initializeReadReceiptManager(); - initializeProfileManager(); initializePeriodicTasks(); - SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager()); + SSKEnvironment.Companion.configure(typingStatusRepository, readReceiptManager, profileManager, getMessageNotifier(), expiringMessageManager); initializeWebRtc(); initializeBlobProvider(); resubmitProfilePictureIfNeeded(); @@ -248,6 +269,12 @@ public void onCreate() { NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); + snodeClock.start(); + pushRegistrationHandler.run(); + configUploader.start(); + configToDatabaseSync.start(); + removeGroupMemberHandler.start(); + // add our shortcut debug menu if we are not in a release build if (BuildConfig.BUILD_TYPE != "release") { // add the config settings shortcut @@ -285,6 +312,7 @@ public void onStart(@NonNull LifecycleOwner owner) { startPollingIfNeeded(); OpenGroupManager.INSTANCE.startPolling(); + return Unit.INSTANCE; }); // fetch last version data @@ -296,11 +324,12 @@ public void onStop(@NonNull LifecycleOwner owner) { isAppVisible = false; Log.i(TAG, "App is no longer visible."); KeyCachingService.onAppBackgrounded(this); - messageNotifier.setVisibleThread(-1); + getMessageNotifier().setVisibleThread(-1); if (poller != null) { poller.stopIfNeeded(); } - ClosedGroupPollerV2.getShared().stopAll(); + pollerFactory.stopAll(); + LegacyClosedGroupPollerV2.getShared().stopAll(); versionDataFetcher.stopTimedVersionCheck(); } @@ -308,29 +337,36 @@ public void onStop(@NonNull LifecycleOwner owner) { public void onTerminate() { stopKovenant(); // Loki OpenGroupManager.INSTANCE.stopPolling(); + pollerFactory.stopAll(); versionDataFetcher.stopTimedVersionCheck(); super.onTerminate(); } + @Deprecated(message = "Use proper DI to inject this component") public ExpiringMessageManager getExpiringMessageManager() { return expiringMessageManager; } + @Deprecated(message = "Use proper DI to inject this component") public TypingStatusRepository getTypingStatusRepository() { return typingStatusRepository; } + @Deprecated(message = "Use proper DI to inject this component") public TypingStatusSender getTypingStatusSender() { return typingStatusSender; } + @Deprecated(message = "Use proper DI to inject this component") + public TextSecurePreferences getTextSecurePreferences() { + return textSecurePreferences; + } + + @Deprecated(message = "Use proper DI to inject this component") public ReadReceiptManager getReadReceiptManager() { return readReceiptManager; } - public ProfileManager getProfileManager() { - return profileManager; - } public boolean isAppVisible() { return isAppVisible; @@ -374,26 +410,6 @@ private void initializeCrashHandling() { Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler)); } - private void initializeExpiringMessageManager() { - this.expiringMessageManager = new ExpiringMessageManager(this); - } - - private void initializeTypingStatusRepository() { - this.typingStatusRepository = new TypingStatusRepository(); - } - - private void initializeReadReceiptManager() { - this.readReceiptManager = new ReadReceiptManager(); - } - - private void initializeProfileManager() { - this.profileManager = new ProfileManager(this, configFactory); - } - - private void initializeTypingStatusSender() { - this.typingStatusSender = new TypingStatusSender(this); - } - private void initializePeriodicTasks() { BackgroundPollWorker.schedulePeriodic(this); } @@ -414,13 +430,9 @@ private void initializeBlobProvider() { private static class ProviderInitializationException extends RuntimeException { } private void setUpPollingIfNeeded() { - String userPublicKey = TextSecurePreferences.getLocalNumber(this); + String userPublicKey = textSecurePreferences.getLocalNumber(); if (userPublicKey == null) return; - if (poller != null) { - poller.setUserPublicKey(userPublicKey); - return; - } - poller = new Poller(configFactory, new Timer()); + poller = new Poller(configFactory, storage, lokiAPIDatabase); } public void startPollingIfNeeded() { @@ -428,7 +440,8 @@ public void startPollingIfNeeded() { if (poller != null) { poller.startIfNeeded(); } - ClosedGroupPollerV2.getShared().start(); + pollerFactory.startAll(); + LegacyClosedGroupPollerV2.getShared().start(); } public void retrieveUserProfile() { @@ -444,7 +457,6 @@ private void resubmitProfilePictureIfNeeded() { private void loadEmojiSearchIndexIfNeeded() { Executors.newSingleThreadExecutor().execute(() -> { - EmojiSearchDatabase emojiSearchDb = getDatabaseComponent().emojiSearchDatabase(); if (emojiSearchDb.query("face", 1).isEmpty()) { try (InputStream inputStream = getAssets().open("emoji/emoji_search_index.json")) { List searchIndex = Arrays.asList(JsonUtil.fromJson(inputStream, EmojiSearchData[].class)); @@ -470,7 +482,7 @@ public boolean clearAllData() { Log.d("Loki", "Failed to delete database."); return false; } - configFactory.keyPairChanged(); + configFactory.clearAll(); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java index 31d146c45f9..8682579b2c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java @@ -49,7 +49,7 @@ private void updateNotifications(final Context context) { new AsyncTask() { @Override protected Void doInBackground(Void... params) { - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); + ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context); return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 00be42e299a..c714aa0eeaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -406,6 +406,7 @@ private void forward() { @SuppressWarnings("CodeBlock2Expr") @SuppressLint("InlinedApi") private void saveToDisk() { + Log.w("ACL", "Asked to save to disk!"); MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null) return; @@ -439,7 +440,7 @@ private String getPermanentlyDeniedStorageText(){ } private void sendMediaSavedNotificationIfNeeded() { - if (conversationRecipient.isGroupRecipient()) return; + if (conversationRecipient.isGroupOrCommunityRecipient()) return; DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); MessageSender.send(message, conversationRecipient.getAddress()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 3e9fb7bcfb1..e948f9da3c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -151,11 +151,11 @@ class SessionDialogBuilder(val context: Context) { fun dangerButton( @StringRes text: Int, - @StringRes contentDescription: Int = text, + @StringRes contentDescriptionRes: Int = text, listener: () -> Unit = {} ) = button( text, - contentDescription, + contentDescriptionRes, R.style.Widget_Session_Button_Dialog_DangerText, ) { listener() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 06e344a2392..6d1ff33d6a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -7,7 +7,6 @@ import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.MarkAsDeletedMessage -import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState @@ -249,6 +248,51 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) } } + override fun markMessagesAsDeleted( + threadId: Long, + serverHashes: List, + displayedMessage: String + ) { + val sendersForHashes = DatabaseComponent.get(context).lokiMessageDatabase() + .getSendersForHashes(threadId, serverHashes.toSet()) + + val smsMessages = sendersForHashes.asSequence() + .filter { it.isSms } + .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } + .toList() + + val mmsMessages = sendersForHashes.asSequence() + .filter { !it.isSms } + .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } + .toList() + + markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) + markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + } + + override fun markUserMessagesAsDeleted( + threadId: Long, + until: Long, + sender: String, + displayedMessage: String + ) { + val mmsMessages = mutableListOf() + val smsMessages = mutableListOf() + + DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender) + .filter { it.timestamp <= until } + .forEach { record -> + if (record.isMms) { + mmsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) + } else { + smsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) + } + } + + markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) + markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + } + override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms) 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 70834a02ded..18bf937cad4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -53,19 +53,19 @@ class ProfilePictureView @JvmOverloads constructor( fun update(recipient: Recipient) { this.recipient = recipient - recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) } + recipient.run { update(address, isLegacyGroupRecipient, isCommunityInboxRecipient) } } fun update( address: Address, - isClosedGroupRecipient: Boolean = false, - isOpenGroupInboxRecipient: Boolean = false + isLegacyGroupRecipient: Boolean = false, + isCommunityInboxRecipient: Boolean = false ) { fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() ?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR) ?: publicKey - if (isClosedGroupRecipient) { + if (isLegacyGroupRecipient) { val members = DatabaseComponent.get(context).groupDatabase() .getGroupMemberAddresses(address.toGroupString(), true) .sorted() @@ -83,7 +83,7 @@ class ProfilePictureView @JvmOverloads constructor( additionalPublicKey = apk additionalDisplayName = getUserDisplayName(apk) } - } else if(isOpenGroupInboxRecipient) { + } else if(isCommunityInboxRecipient) { val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize()) this.publicKey = publicKey displayName = getUserDisplayName(publicKey) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java index c2922fbfc01..d4c1ea98bb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -3,32 +3,35 @@ import android.annotation.SuppressLint; import android.content.Context; -import androidx.annotation.NonNull; import org.session.libsession.messaging.messages.control.TypingIndicator; import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.util.SessionMetaProtocol; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Singleton; + @SuppressLint("UseSparseArrays") +@Singleton public class TypingStatusSender { private static final long REFRESH_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(10); private static final long PAUSE_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(3); - private final Context context; private final Map selfTypingTimers; + private final ThreadDatabase threadDatabase; - public TypingStatusSender(@NonNull Context context) { - this.context = context; - this.selfTypingTimers = new HashMap<>(); + @Inject + public TypingStatusSender(ThreadDatabase threadDatabase) { + this.threadDatabase = threadDatabase; + this.selfTypingTimers = new HashMap<>(); } public synchronized void onTypingStarted(long threadId) { @@ -77,7 +80,6 @@ private synchronized void onTypingStopped(long threadId, boolean notify) { } private void sendTyping(long threadId, boolean typingStarted) { - ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); Recipient recipient = threadDatabase.getRecipientForThreadId(threadId); if (recipient == null) { return; } if (!SessionMetaProtocol.shouldSendTypingIndicator(recipient)) { return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt index 7b92e505c6b..2e7c027b1e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -8,7 +8,6 @@ import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isGone -import androidx.core.widget.ImageViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt new file mode 100644 index 00000000000..3b5ba393f9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -0,0 +1,408 @@ +package org.thoughtcrime.securesms.configs + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +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.ReadableGroupInfoConfig +import network.loki.messenger.libsession_util.ReadableUserGroupsConfig +import network.loki.messenger.libsession_util.ReadableUserProfile +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.Conversation +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 network.loki.messenger.libsession_util.util.afterSend +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.jobs.BackgroundGroupAddJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.crypto.ecc.DjbECPrivateKey +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.crypto.ecc.ECKeyPair +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.PollerFactory +import org.thoughtcrime.securesms.groups.ClosedGroupManager +import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.sskenvironment.ProfileManager +import javax.inject.Inject + +private const val TAG = "ConfigToDatabaseSync" + +/** + * This class is responsible for syncing config system's data into the database. + * + * It does so by listening to the [ConfigFactoryProtocol.configUpdateNotifications] and updating the database accordingly. + * + * @see ConfigUploader For upload config system data into swarm automagically. + */ +class ConfigToDatabaseSync @Inject constructor( + @ApplicationContext private val context: Context, + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val threadDatabase: ThreadDatabase, + private val recipientDatabase: RecipientDatabase, + private val mmsDatabase: MmsDatabase, + private val pollerFactory: PollerFactory, + private val clock: SnodeClock, + private val profileManager: ProfileManager, + private val preferences: TextSecurePreferences, +) { + private var job: Job? = null + + fun start() { + require(job == null) { "Already started" } + + @Suppress("OPT_IN_USAGE") + job = GlobalScope.launch { + supervisorScope { + val job1 = async { + configFactory.configUpdateNotifications + .filterIsInstance() + .debounce(800L) + .collect { config -> + try { + Log.i(TAG, "Start syncing user configs") + syncUserConfigs(config.configType, config.timestamp) + Log.i(TAG, "Finished syncing user configs") + } catch (e: Exception) { + Log.e(TAG, "Error syncing user configs", e) + } + } + } + + val job2 = async { + configFactory.configUpdateNotifications + .filterIsInstance() + .collect { + syncGroupConfigs(it.groupId) + } + } + + job1.await() + job2.await() + } + } + } + + private fun syncGroupConfigs(groupId: AccountId) { + val info = configFactory.withGroupConfigs(groupId) { + UpdateGroupInfo(it.groupInfo) + } + + updateGroup(info) + } + + private fun syncUserConfigs(userConfigType: UserConfigType, updateTimestamp: Long) { + val configUpdate = configFactory.withUserConfigs { configs -> + when (userConfigType) { + UserConfigType.USER_PROFILE -> UpdateUserInfo(configs.userProfile) + UserConfigType.USER_GROUPS -> UpdateUserGroupsInfo(configs.userGroups) + UserConfigType.CONTACTS -> UpdateContacts(configs.contacts.all()) + UserConfigType.CONVO_INFO_VOLATILE -> UpdateConvoVolatile(configs.convoInfoVolatile.all()) + } + } + + when (configUpdate) { + is UpdateUserInfo -> updateUser(configUpdate, updateTimestamp) + is UpdateUserGroupsInfo -> updateUserGroups(configUpdate, updateTimestamp) + is UpdateContacts -> updateContacts(configUpdate, updateTimestamp) + is UpdateConvoVolatile -> updateConvoVolatile(configUpdate) + else -> error("Unknown config update type: $configUpdate") + } + } + + private data class UpdateUserInfo( + val name: String?, + val userPic: UserPic, + val ntsPriority: Long, + val ntsExpiry: ExpiryMode + ) { + constructor(profile: ReadableUserProfile) : this( + name = profile.getName(), + userPic = profile.getPic(), + ntsPriority = profile.getNtsPriority(), + ntsExpiry = profile.getNtsExpiry() + ) + } + + private fun updateUser(userProfile: UpdateUserInfo, messageTimestamp: Long) { + val userPublicKey = storage.getUserPublicKey() ?: return + // would love to get rid of recipient and context from this + val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) + + // Update profile name + userProfile.name?.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let { + preferences.setProfileName(it) + profileManager.setName(context, recipient, it) + } + + // Update profile picture + if (userProfile.userPic == UserPic.DEFAULT) { + storage.clearUserPic(clearConfig = false) + } else if (userProfile.userPic.key.isNotEmpty() && userProfile.userPic.url.isNotEmpty() + && preferences.getProfilePictureURL() != userProfile.userPic.url + ) { + storage.setUserProfilePicture(userProfile.userPic.url, userProfile.userPic.key) + } + + if (userProfile.ntsPriority == PRIORITY_HIDDEN) { + // delete nts thread if needed + val ourThread = storage.getThreadId(recipient) ?: return + storage.deleteConversation(ourThread) + } else { + // create note to self thread if needed (?) + val address = recipient.address + val ourThread = storage.getThreadId(address) ?: storage.getOrCreateThreadIdFor(address).also { + storage.setThreadDate(it, 0) + } + threadDatabase.setHasSent(ourThread, true) + storage.setPinned(ourThread, userProfile.ntsPriority > 0) + } + + // Set or reset the shared library to use latest expiration config + storage.getThreadId(recipient)?.let { + storage.setExpirationConfiguration( + storage.getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: + ExpirationConfiguration(it, userProfile.ntsExpiry, messageTimestamp) + ) + } + } + + private data class UpdateGroupInfo( + val id: AccountId, + val name: String?, + val destroyed: Boolean, + val deleteBefore: Long?, + val deleteAttachmentsBefore: Long? + ) { + constructor(groupInfoConfig: ReadableGroupInfoConfig) : this( + id = groupInfoConfig.id(), + name = groupInfoConfig.getName(), + destroyed = groupInfoConfig.isDestroyed(), + deleteBefore = groupInfoConfig.getDeleteBefore(), + deleteAttachmentsBefore = groupInfoConfig.getDeleteAttachmentsBefore() + ) + } + + private fun updateGroup(groupInfoConfig: UpdateGroupInfo) { + val threadId = storage.getThreadId(fromSerialized(groupInfoConfig.id.hexString)) ?: return + val recipient = storage.getRecipientForThread(threadId) ?: return + recipientDatabase.setProfileName(recipient, groupInfoConfig.name) + profileManager.setName(context, recipient, groupInfoConfig.name ?: "") + + if (groupInfoConfig.destroyed) { + storage.clearMessages(threadId) + } else { + groupInfoConfig.deleteBefore?.let { removeBefore -> + storage.trimThreadBefore(threadId, removeBefore) + } + groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> + mmsDatabase.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true) + } + } + } + + private data class UpdateContacts(val contacts: List) + + private fun updateContacts(contacts: UpdateContacts, messageTimestamp: Long) { + storage.addLibSessionContacts(contacts.contacts, messageTimestamp) + } + + private data class UpdateUserGroupsInfo( + val communityInfo: List, + val legacyGroupInfo: List, + val closedGroupInfo: List + ) { + constructor(userGroups: ReadableUserGroupsConfig) : this( + communityInfo = userGroups.allCommunityInfo(), + legacyGroupInfo = userGroups.allLegacyGroupInfo(), + closedGroupInfo = userGroups.allClosedGroupInfo() + ) + } + + private fun updateUserGroups(userGroups: UpdateUserGroupsInfo, messageTimestamp: Long) { + val localUserPublicKey = storage.getUserPublicKey() ?: return Log.w( + TAG, + "No user public key when trying to update user groups from config" + ) + val allOpenGroups = storage.getAllOpenGroups() + val toDeleteCommunities = allOpenGroups.filter { + Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in userGroups.communityInfo.map { it.community.fullUrl() } + } + + val existingCommunities: Map = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys } + val toAddCommunities = userGroups.communityInfo.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } } + val existingJoinUrls = existingCommunities.values.map { it.joinURL } + + val existingLegacyClosedGroups = storage.getAllGroups(includeInactive = true).filter { it.isLegacyGroup } + val lgcIds = userGroups.legacyGroupInfo.map { it.accountId } + val toDeleteLegacyClosedGroups = existingLegacyClosedGroups.filter { group -> + GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds + } + + // delete the ones which are not listed in the config + toDeleteCommunities.values.forEach { openGroup -> + OpenGroupManager.delete(openGroup.server, openGroup.room, context) + } + + toDeleteLegacyClosedGroups.forEach { deleteGroup -> + val threadId = storage.getThreadId(deleteGroup.encodedId) + if (threadId != null) { + ClosedGroupManager.silentlyRemoveGroup(context,threadId, + GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true) + } + } + + toAddCommunities.forEach { toAddCommunity -> + val joinUrl = toAddCommunity.community.fullUrl() + if (!storage.hasBackgroundGroupAddJob(joinUrl)) { + JobQueue.shared.add(BackgroundGroupAddJob(joinUrl)) + } + } + + for (groupInfo in userGroups.communityInfo) { + val groupBaseCommunity = groupInfo.community + if (groupBaseCommunity.fullUrl() in existingJoinUrls) { + // add it + val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() } + threadDatabase.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED) + } + } + + val existingClosedGroupThreads: Map = threadDatabase.readerFor(threadDatabase.conversationList).use { reader -> + buildMap(reader.count) { + var current = reader.next + while (current != null) { + if (current.recipient?.isGroupV2Recipient == true) { + put(AccountId(current.recipient.address.serialize()), current.threadId) + } + + current = reader.next + } + } + } + + val groupThreadsToKeep = hashMapOf() + + for (closedGroup in userGroups.closedGroupInfo) { + val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId.hexString), false) + storage.setRecipientApprovedMe(recipient, true) + storage.setRecipientApproved(recipient, !closedGroup.invited) + val threadId = storage.getOrCreateThreadIdFor(recipient.address) + groupThreadsToKeep[closedGroup.groupAccountId] = threadId + + storage.setPinned(threadId, closedGroup.priority == PRIORITY_PINNED) + if (!closedGroup.invited) { + pollerFactory.pollerFor(closedGroup.groupAccountId)?.start() + } + } + + val toRemove = existingClosedGroupThreads - groupThreadsToKeep.keys + Log.d(TAG, "Removing ${toRemove.size} closed groups") + toRemove.forEach { (groupId, threadId) -> + pollerFactory.pollerFor(groupId)?.stop() + storage.removeClosedGroupThread(threadId) + } + + for (group in userGroups.legacyGroupInfo) { + val groupId = GroupUtil.doubleEncodeGroupID(group.accountId) + val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId } + val existingThread = existingGroup?.let { storage.getThreadId(existingGroup.encodedId) } + if (existingGroup != null) { + if (group.priority == PRIORITY_HIDDEN && existingThread != null) { + ClosedGroupManager.silentlyRemoveGroup(context,existingThread, + GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true) + } else if (existingThread == null) { + Log.w(TAG, "Existing group had no thread to hide") + } else { + Log.d(TAG, "Setting existing group pinned status to ${group.priority}") + threadDatabase.setPinned(existingThread, group.priority == PRIORITY_PINNED) + } + } else { + val members = group.members.keys.map { fromSerialized(it) } + val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { fromSerialized(it) } + val title = group.name + val formationTimestamp = (group.joinedAt * 1000L) + storage.createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) + storage.setProfileSharing(fromSerialized(groupId), true) + // Add the group to the user's set of public keys to poll for + storage.addClosedGroupPublicKey(group.accountId) + // Store the encryption key pair + val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) + storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills()) + // Notify the PN server + PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey) + // Notify the user + val threadID = storage.getOrCreateThreadIdFor(fromSerialized(groupId)) + threadDatabase.setDate(threadID, formationTimestamp) + + // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group, + // which in turn allows us to show the `groupNoMessages` control message text. + //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 + LegacyClosedGroupPollerV2.shared.startPolling(group.accountId) + } + storage.getThreadId(fromSerialized(groupId))?.let { + storage.setExpirationConfiguration( + storage.getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } + ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) + ) + } + } + } + + private data class UpdateConvoVolatile(val convos: List) + + private fun updateConvoVolatile(convos: UpdateConvoVolatile) { + val extracted = convos.convos.filterNotNull() + for (conversation in extracted) { + val threadId = when (conversation) { + is Conversation.OneToOne -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false) + is Conversation.LegacyGroup -> storage.getThreadIdFor("", conversation.groupId,null, createThread = false) + is Conversation.Community -> storage.getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) + is Conversation.ClosedGroup -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false) // New groups will be managed bia libsession + } + if (threadId != null) { + if (conversation.lastRead > storage.getLastSeen(threadId)) { + storage.markConversationAsRead(threadId, conversation.lastRead, force = true) + storage.updateThread(threadId, false) + } + } + } + } +} + +/** + * Truncate a string to a specified number of bytes + * + * This could split multi-byte characters/emojis. + */ +private fun String.truncate(sizeInBytes: Int): String = + toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt new file mode 100644 index 00000000000..8e8651539e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -0,0 +1,277 @@ +package org.thoughtcrime.securesms.configs + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import network.loki.messenger.libsession_util.util.ConfigPush +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.snode.model.StoreMessageResponse +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigPushResult +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.getGroup +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.Snode +import org.session.libsignal.utilities.retryWithUniformInterval +import javax.inject.Inject + +private const val TAG = "ConfigUploader" + +/** + * This class is responsible for sending the local config changes to the swarm. + * + * Note: This class is listening ONLY to the config system changes. If you change any local database + * data, this class will not be aware of it. You'll need to update the config system + * for this class to pick up these changes. + * + * @see ConfigToDatabaseSync For syncing the config changes to the local database. + * + * It does so by listening for changes in the config factory. + */ +class ConfigUploader @Inject constructor( + private val configFactory: ConfigFactoryProtocol, + private val storageProtocol: StorageProtocol, + private val clock: SnodeClock, +) { + private var job: Job? = null + + @OptIn(DelicateCoroutinesApi::class, FlowPreview::class) + fun start() { + require(job == null) { "Already started" } + + job = GlobalScope.launch { + supervisorScope { + val job1 = launch { + configFactory.configUpdateNotifications + .filterIsInstance() + .debounce(1000L) + .collect { + try { + retryWithUniformInterval { + pushUserConfigChangesIfNeeded() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to push user configs", e) + } + } + } + + val job2 = launch { + configFactory.configUpdateNotifications + .filterIsInstance() + .debounce(1000L) + .collect { changes -> + try { + retryWithUniformInterval { + pushGroupConfigsChangesIfNeeded(changes.groupId) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to push group configs", e) + } + } + } + + job1.join() + job2.join() + } + } + } + + private suspend fun pushGroupConfigsChangesIfNeeded(groupId: AccountId) = coroutineScope { + // Only admin can push group configs + val adminKey = configFactory.getGroup(groupId)?.adminKey + if (adminKey == null) { + Log.i(TAG, "Skipping group config push without admin key") + return@coroutineScope + } + + // Gather data to push + val (membersPush, infoPush, keysPush) = configFactory.withMutableGroupConfigs(groupId) { configs -> + val membersPush = if (configs.groupMembers.needsPush()) { + configs.groupMembers.push() + } else { + null + } + + val infoPush = if (configs.groupInfo.needsPush()) { + configs.groupInfo.push() + } else { + null + } + + Triple(membersPush, infoPush, configs.groupKeys.pendingConfig()) + } + + // Nothing to push? + if (membersPush == null && infoPush == null && keysPush == null) { + return@coroutineScope + } + + Log.d(TAG, "Pushing group configs") + + val snode = SnodeAPI.getSingleTargetSnode(groupId.hexString).await() + val auth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) + + // Spawn the config pushing concurrently + val membersConfigHashTask = membersPush?.let { + async { + membersPush to pushConfig( + auth, + snode, + membersPush, + Namespace.CLOSED_GROUP_MEMBERS() + ) + } + } + + val infoConfigHashTask = infoPush?.let { + async { + infoPush to pushConfig(auth, snode, infoPush, Namespace.CLOSED_GROUP_INFO()) + } + } + + // Keys push is different: it doesn't have the delete call so we don't call pushConfig + val keysPushResult = keysPush?.let { + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = auth.accountId.hexString, + request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + Namespace.ENCRYPTION_KEYS(), + SnodeMessage( + auth.accountId.hexString, + Base64.encodeBytes(keysPush), + SnodeMessage.CONFIG_TTL, + clock.currentTimeMills(), + ), + auth + ), + responseType = StoreMessageResponse::class.java + ).toConfigPushResult() + } + + // Wait for all other config push to come back + val memberPushResult = membersConfigHashTask?.await() + val infoPushResult = infoConfigHashTask?.await() + + configFactory.confirmGroupConfigsPushed( + groupId, + memberPushResult, + infoPushResult, + keysPushResult + ) + + Log.i( + TAG, + "Pushed group configs, " + + "info = ${infoPush != null}, " + + "members = ${membersPush != null}, " + + "keys = ${keysPush != null}" + ) + } + + private suspend fun pushConfig( + auth: SwarmAuth, + snode: Snode, + push: ConfigPush, + namespace: Int + ): ConfigPushResult { + val response = SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = auth.accountId.hexString, + request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace, + SnodeMessage( + auth.accountId.hexString, + Base64.encodeBytes(push.config), + SnodeMessage.CONFIG_TTL, + clock.currentTimeMills(), + ), + auth, + ), + responseType = StoreMessageResponse::class.java + ) + + if (push.obsoleteHashes.isNotEmpty()) { + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = auth.accountId.hexString, + request = SnodeAPI.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) + ) + } + + return response.toConfigPushResult() + } + + private suspend fun pushUserConfigChangesIfNeeded() = coroutineScope { + val userAuth = requireNotNull(storageProtocol.userAuth) { + "Current user not available" + } + + // Gather all the user configs that need to be pushed + val pushes = configFactory.withMutableUserConfigs { configs -> + UserConfigType.entries + .mapNotNull { type -> + val config = configs.getConfig(type) + if (!config.needsPush()) { + return@mapNotNull null + } + + type to config.push() + } + } + + if (pushes.isEmpty()) { + return@coroutineScope + } + + Log.d(TAG, "Pushing ${pushes.size} user configs") + + val snode = SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).await() + + val pushTasks = pushes.map { (configType, configPush) -> + async { + (configType to configPush) to pushConfig( + userAuth, + snode, + configPush, + configType.namespace + ) + } + } + + val pushResults = + pushTasks.awaitAll().associate { it.first.first to (it.first.second to it.second) } + + Log.d(TAG, "Pushed ${pushResults.size} user configs") + + configFactory.confirmUserConfigsPushed( + contacts = pushResults[UserConfigType.CONTACTS], + userGroups = pushResults[UserConfigType.USER_GROUPS], + convoInfoVolatile = pushResults[UserConfigType.CONVO_INFO_VOLATILE], + userProfile = pushResults[UserConfigType.USER_PROFILE] + ) + } + + private fun StoreMessageResponse.toConfigPushResult(): ConfigPushResult { + return ConfigPushResult(hash, timestamp) + } +} \ No newline at end of file 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 0db2ec89622..7bfb8dd54c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -33,7 +33,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St } val list = mutableListOf() if (isFlagSet(DisplayMode.FLAG_CLOSED_GROUPS)) { - list.addAll(getClosedGroups(contacts)) + list.addAll(getGroups(contacts)) } if (isFlagSet(DisplayMode.FLAG_OPEN_GROUPS)) { list.addAll(getCommunities(contacts)) @@ -46,14 +46,12 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St private fun getContacts(contacts: List): List { return getItems(contacts, context.getString(R.string.contactContacts)) { - !it.isGroupRecipient + !it.isGroupOrCommunityRecipient } } - private fun getClosedGroups(contacts: List): List { - return getItems(contacts, context.getString(R.string.conversationsGroups)) { - it.address.isClosedGroup - } + private fun getGroups(contacts: List): List { + return getItems(contacts, context.getString(R.string.conversationsGroups)) { it.address.isGroup } } private fun getCommunities(contacts: List): List { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt index b1706affeae..5b9c5ad9d1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt @@ -9,7 +9,7 @@ class SelectContactsLoader(context: Context, private val usersToExclude: Set { val contacts = ContactUtilities.getAllContacts(context) return contacts.filter { - !it.isGroupRecipient && !usersToExclude.contains(it.address.toString()) && it.hasApprovedMe() + !it.isGroupOrCommunityRecipient && !usersToExclude.contains(it.address.toString()) && it.hasApprovedMe() }.map { it.address.toString() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index 77f579e14d9..8cfd58f0979 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -56,7 +56,7 @@ class UserView : LinearLayout { val address = user.address.serialize() binding.profilePictureView.update(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) - binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) + binding.nameTextView.text = if (user.isGroupOrCommunityRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { ActionIndicator.None -> { binding.actionIndicatorImageView.visibility = View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 2a88aac463e..93ec9d0d5c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -25,6 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.database.Storage import javax.inject.Inject @AndroidEntryPoint @@ -37,6 +38,7 @@ class ConversationActionBarView @JvmOverloads constructor( @Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var groupDb: GroupDatabase + @Inject lateinit var storage: Storage var delegate: ConversationActionBarDelegate? = null @@ -46,6 +48,9 @@ class ConversationActionBarView @JvmOverloads constructor( } } + val profilePictureView + get() = binding.profilePictureView + init { var previousState: Int var currentState = 0 @@ -75,7 +80,7 @@ class ConversationActionBarView @JvmOverloads constructor( ) { this.delegate = delegate binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( - if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size + if (recipient.isGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size ).let { LayoutParams(it, it) } update(recipient, openGroup, config) } @@ -128,12 +133,16 @@ class ConversationActionBarView @JvmOverloads constructor( ) } - if (recipient.isGroupRecipient) { + if (recipient.isGroupOrCommunityRecipient) { val title = if (recipient.isCommunityRecipient) { val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 resources.getQuantityString(R.plurals.membersActive, userCount, userCount) } else { - val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + val userCount = if (recipient.isGroupV2Recipient) { + storage.getMembers(recipient.address.serialize()).size + } else { // legacy closed groups + groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + } resources.getQuantityString(R.plurals.members, userCount, userCount) } settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index e086c959245..0988094b55a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -4,11 +4,12 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol @@ -19,19 +20,18 @@ import org.session.libsession.utilities.getExpirationTypeDisplayValue import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.getSubbedCharSequence -import org.thoughtcrime.securesms.ui.getSubbedString -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds class DisappearingMessages @Inject constructor( - @ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, private val messageExpirationManager: MessageExpirationManagerProtocol, + private val storage: StorageProtocol, + private val clock: SnodeClock, ) { fun set(threadId: Long, address: Address, mode: ExpiryMode, isGroup: Boolean) { - val expiryChangeTimestampMs = SnodeAPI.nowWithOffset - MessagingModuleConfiguration.shared.storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs)) + val expiryChangeTimestampMs = clock.currentTimeMills() + storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs)) val message = ExpirationTimerUpdate(isGroup = isGroup).apply { expiryMode = mode @@ -43,7 +43,6 @@ class DisappearingMessages @Inject constructor( messageExpirationManager.insertExpirationTimerMessage(message) MessageSender.send(message, address) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { @@ -58,9 +57,9 @@ class DisappearingMessages @Inject constructor( dangerButton( text = if (message.expiresIn == 0L) R.string.confirm else R.string.set, - contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton + contentDescriptionRes = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton ) { - set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) + set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isGroupRecipient) } cancelButton() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 915ff669711..6cdf8678bb7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.messages.ExpirationConfiguration -import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState @@ -32,7 +31,6 @@ class DisappearingMessagesViewModel( private val threadId: Long, private val application: Application, private val textSecurePreferences: TextSecurePreferences, - private val messageExpirationManager: MessageExpirationManagerProtocol, private val disappearingMessages: DisappearingMessages, private val threadDb: ThreadDatabase, private val groupDb: GroupDatabase, @@ -59,16 +57,27 @@ class DisappearingMessagesViewModel( init { viewModelScope.launch { val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE - val recipient = threadDb.getRecipientForThreadId(threadId) - val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } - ?.run { groupDb.getGroup(address.toGroupString()).orNull() } + val recipient = threadDb.getRecipientForThreadId(threadId)?: return@launch + + val isAdmin = when { + recipient.isGroupV2Recipient -> { + // Handle the new closed group functionality + storage.getMembers(recipient.address.serialize()).any { it.sessionId == textSecurePreferences.getLocalNumber() && it.admin } + } + recipient.isLegacyGroupRecipient -> { + val groupRecord = groupDb.getGroup(recipient.address.toGroupString()).orNull() + // Handle as legacy group + groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true + } + else -> !recipient.isGroupOrCommunityRecipient + } _state.update { it.copy( - address = recipient?.address, - isGroup = groupRecord != null, - isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(), - isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, + address = recipient.address, + isGroup = recipient.isGroupRecipient, + isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(), + isSelfAdmin = isAdmin, expiryMode = expiryMode, persistedMode = expiryMode ) @@ -102,7 +111,6 @@ class DisappearingMessagesViewModel( @Assisted private val threadId: Long, private val application: Application, private val textSecurePreferences: TextSecurePreferences, - private val messageExpirationManager: MessageExpirationManagerProtocol, private val disappearingMessages: DisappearingMessages, private val threadDb: ThreadDatabase, private val groupDb: GroupDatabase, @@ -113,7 +121,6 @@ class DisappearingMessagesViewModel( threadId, application, textSecurePreferences, - messageExpirationManager, disappearingMessages, threadDb, groupDb, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index b066a96cc7e..15025a411af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages.ui -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -12,19 +11,19 @@ import androidx.compose.material3.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.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.fadingEdges import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -38,15 +37,12 @@ fun DisappearingMessages( modifier: Modifier = Modifier, callbacks: ExpiryCallbacks = NoOpCallbacks ) { - val scrollState = rememberScrollState() - Column(modifier = modifier.padding(horizontal = LocalDimensions.current.spacing)) { - Box(modifier = Modifier.weight(1f)) { + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> Column( modifier = Modifier - .padding(vertical = LocalDimensions.current.spacing) - .verticalScroll(scrollState) - .fadingEdges(scrollState), + .verticalScroll(rememberScrollState()) + .padding(vertical = LocalDimensions.current.spacing), ) { state.cards.forEachIndexed { index, option -> OptionsCard(option, callbacks) @@ -69,6 +65,8 @@ fun DisappearingMessages( .fillMaxWidth() .padding(top = LocalDimensions.current.xsSpacing) ) + + Spacer(modifier = Modifier.height(bottomContentPadding)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt index 8dffb1fd9b7..f01eeaea2d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -66,7 +66,7 @@ class StartConversationFragment : BottomSheetDialogFragment(), StartConversation } override fun onCreateGroupSelected() { - replaceFragment(CreateGroupFragment().also { it.delegate = this }) + replaceFragment(CreateGroupFragment()) } override fun onJoinCommunitySelected() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt index bc298c5bd38..bae328c78ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -23,6 +23,7 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.border diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index 0a40c6ee391..e825d412802 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.start.newmessage import android.graphics.Rect import android.os.Build import android.view.ViewTreeObserver -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -49,11 +48,12 @@ import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon -import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -64,7 +64,7 @@ import kotlin.math.max private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun NewMessage( state: State, @@ -142,8 +142,8 @@ private fun EnterAccountId( SessionOutlinedTextField( text = state.newMessageIdOrOns, modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing), - contentDescription = "Session id input box", + .padding(horizontal = LocalDimensions.current.spacing) + .qaTag(stringResource(R.string.AccessibilityId_sessionIdInput)), placeholder = stringResource(R.string.accountIdOrOnsEnter), onChange = callbacks::onChange, onContinue = callbacks::onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt index be7630b536e..4d70f40471c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import java.util.concurrent.TimeoutException import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,12 +13,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import network.loki.messenger.R import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.PublicKeyValidation -import org.session.libsignal.utilities.timeout import org.thoughtcrime.securesms.ui.GetString @HiltViewModel @@ -68,12 +67,14 @@ internal class NewMessageViewModel @Inject constructor( // This could be an ONS name _state.update { it.copy(isTextErrorColor = false, error = null, loading = true) } - loadOnsJob = viewModelScope.launch(Dispatchers.IO) { + loadOnsJob = viewModelScope.launch { try { - val publicKey = SnodeAPI.getAccountID(ons).timeout(30_000).get() - if (isActive) onPublicKey(publicKey) + val publicKey = withTimeout(30_000L, { + SnodeAPI.getAccountID(ons).await() + }) + onPublicKey(publicKey) } catch (e: Exception) { - if (isActive) onError(e) + onError(e) } } } 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 4c027b8d6c2..0ea9e374dd3 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 @@ -37,7 +37,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat -import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle @@ -58,6 +57,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -65,7 +69,8 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -78,7 +83,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.AccountId import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -87,8 +91,11 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY import org.session.libsession.utilities.Stub import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask @@ -98,6 +105,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.AccountId import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext @@ -148,16 +156,15 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ReactionDatabase 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 import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.home.HomeActivity -import org.thoughtcrime.securesms.home.startHomeActivity +import org.thoughtcrime.securesms.home.search.getSearchName import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel @@ -171,12 +178,12 @@ import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.mms.VideoSlide +import org.thoughtcrime.securesms.openUrl import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ActivityDispatcher -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.NetworkUtils @@ -226,10 +233,12 @@ 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 @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory + @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var groupManagerV2: GroupManagerV2 private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { @@ -269,7 +278,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private val viewModel: ConversationViewModel by viewModels { - viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) + viewModelFactory.create(threadId, storage.getUserED25519KeyPair()) } private var actionMode: ActionMode? = null private var unreadCount = Int.MAX_VALUE @@ -404,6 +413,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 @@ -474,10 +484,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpBlockedBanner() binding.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() - setUpMessageRequestsBar() - - // Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the - // keyboard visible and have no need to immediately display it. + setUpMessageRequests() val weakActivity = WeakReference(this) @@ -501,6 +508,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpSearchResultObserver() scrollToFirstUnreadMessageIfNeeded() setUpOutdatedClientBanner() + setUpLegacyGroupBanner() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { binding.conversationRecyclerView.scrollToPosition(targetPosition) @@ -798,7 +806,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpBlockedBanner() { - val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return + val recipient = viewModel.recipient?.takeUnless { it.isGroupOrCommunityRecipient } ?: return binding.blockedBannerTextView.text = applicationContext.getString(R.string.blockBlockedDescription) binding.blockedBanner.isVisible = recipient.isBlocked binding.blockedBanner.setOnClickListener { viewModel.unblock() } @@ -810,13 +818,44 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && legacyRecipient != null - binding.outdatedBanner.isVisible = shouldShowLegacy + binding.outdatedDisappearingBanner.isVisible = shouldShowLegacy if (shouldShowLegacy) { - val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy) + val txt = Phrase.from(this, R.string.disappearingMessagesLegacy) .put(NAME_KEY, legacyRecipient!!.toShortString()) .format() - binding?.outdatedBannerTextView?.text = txt + binding.outdatedDisappearingBannerTextView.text = txt + } + } + + private fun setUpLegacyGroupBanner() { + val shouldDisplayBanner = viewModel.recipient?.isLegacyGroupRecipient ?: return + + //TODO groupsv2, URL + val url = "https://getsession.org" + + with(binding) { + outdatedGroupBanner.isVisible = shouldDisplayBanner + outdatedGroupBanner.text = Phrase.from(this@ConversationActivityV2, R.string.groupLegacyBanner) + //TODO groupsv2, date + .put(DATE_KEY, "") + .format() + outdatedGroupBanner.setOnClickListener { + showSessionDialog { + title(R.string.urlOpenBrowser) + text(Phrase.from(this@ConversationActivityV2, R.string.urlOpenDescription) + .put(URL_KEY, url) + .format()) + cancelButton() + dangerButton(R.string.open) { + try { + openUrl(url) + } catch (e: Exception) { + Log.e(TAG, "Error opening URL", e) + } + } + } + } } } @@ -841,25 +880,46 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun setUpUiStateObserver() { - lifecycleScope.launchWhenStarted { - viewModel.uiState.collect { uiState -> - uiState.uiMessages.firstOrNull()?.let { - Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show() - viewModel.messageShown(it.id) - } - if (uiState.isMessageRequestAccepted == true) { - binding.messageRequestBar.visibility = View.GONE - } - if (!uiState.conversationExists && !isFinishing) { - // Conversation should be deleted now + // Observe toast messages + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState + .mapNotNull { it.uiMessages.firstOrNull() } + .distinctUntilChanged() + .collect { msg -> + Toast.makeText(this@ConversationActivityV2, msg.message, Toast.LENGTH_LONG).show() + viewModel.messageShown(msg.id) + } + } + } + + // When we see "shouldExit", we finish the activity once for all. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // Wait for `shouldExit == true` then finish the activity + viewModel.uiState + .filter { it.shouldExit } + .first() + + if (!isFinishing) { finish() } + } + } - // show or hide the text input - binding.inputBar.isGone = uiState.hideInputBar + // Observe the rest misc "simple" state change. They are bundled in one big + // state observing as these changes are relatively cheap to perform even redundantly. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + binding.inputBar.run { + isVisible = state.showInput + showMediaControls = state.enableInputMediaControls + } - // show or hide loading indicator - binding.loader.isVisible = uiState.showLoader + // show or hide loading indicator + binding.loader.isVisible = state.showLoader + } } } } @@ -892,10 +952,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val recipient = viewModel.recipient ?: return false if (!viewModel.isMessageRequestThread) { ConversationMenuHelper.onPrepareOptionsMenu( - menu, - menuInflater, - recipient, - this + menu = menu, + inflater = menuInflater, + thread = recipient, + context = this, + configFactory = configFactory, ) } maybeUpdateToolbar(recipient) @@ -921,11 +982,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (threadRecipient.isContactRecipient) { binding.blockedBanner.isVisible = threadRecipient.isBlocked } - setUpMessageRequestsBar() invalidateOptionsMenu() updateSendAfterApprovalText() - showOrHideInputIfNeeded() - maybeUpdateToolbar(threadRecipient) } } @@ -938,27 +996,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText } - private fun showOrHideInputIfNeeded() { - binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } - ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } - ?: true - } - - private fun setUpMessageRequestsBar() { - binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread() - binding.messageRequestBar.isVisible = isIncomingMessageRequestThread() + private fun setUpMessageRequests() { binding.acceptMessageRequestButton.setOnClickListener { - acceptMessageRequest() + viewModel.acceptMessageRequest() } + binding.messageRequestBlock.setOnClickListener { block(deleteThread = true) } + binding.declineMessageRequestButton.setOnClickListener { fun doDecline() { viewModel.declineMessageRequest() - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) - } finish() } @@ -969,27 +1018,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe button(R.string.cancel) } } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState + .map { it.messageRequestState } + .distinctUntilChanged() + .collectLatest { state -> + binding.messageRequestBar.isVisible = state is MessageRequestUiState.Visible + + if (state is MessageRequestUiState.Visible) { + binding.sendAcceptsTextView.setText(state.acceptButtonText) + binding.messageRequestBlock.isVisible = state.showBlockButton + binding.declineMessageRequestButton.setText(state.declineButtonText) + } + } + } + } } private fun acceptMessageRequest() { binding.messageRequestBar.isVisible = false viewModel.acceptMessageRequest() - - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) - } } - private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run { - !isGroupRecipient && !isLocalNumber && - !(hasApprovedMe() || viewModel.hasReceived()) - } ?: false - - private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run { - !isGroupRecipient && !isApproved && !isLocalNumber && - !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0 - } ?: false - override fun inputBarEditTextContentChanged(newContent: CharSequence) { val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead if (textSecurePreferences.isLinkPreviewsEnabled()) { @@ -1142,7 +1194,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } // 10n1 and groups - recipient.is1on1 || recipient.isGroupRecipient -> { + recipient.is1on1 || recipient.isGroupOrCommunityRecipient -> { Phrase.from(applicationContext, R.string.groupNoMessages) .put(GROUP_NAME_KEY, recipient.toShortString()) .format() @@ -1185,25 +1237,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (item.itemId == android.R.id.home) { return false } - return viewModel.recipient?.let { recipient -> - ConversationMenuHelper.onOptionItemSelected(this, item, recipient) - } ?: false + + return viewModel.onOptionItemSelected(this, item) } override fun block(deleteThread: Boolean) { val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action") + val invitingAdmin = viewModel.invitingAdmin + + val name = if (recipient.isGroupV2Recipient && invitingAdmin != null) { + invitingAdmin.getSearchName() + } else { + recipient.toShortString() + } + showSessionDialog { title(R.string.block) text( Phrase.from(context, R.string.blockDescription) - .put(NAME_KEY, recipient.toShortString()) + .put(NAME_KEY, name) .format() ) dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { viewModel.block() // Block confirmation toast added as per SS-64 - val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, recipient.toShortString()).format().toString() + val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, name).format().toString() Toast.makeText(context, txt, Toast.LENGTH_LONG).show() if (deleteThread) { @@ -1234,8 +1293,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } + // TODO: don't need to allow new closed group check here, removed in new disappearing messages override fun showDisappearingMessages(thread: Recipient) { - if (thread.isClosedGroupRecipient) { + if (thread.isLegacyGroupRecipient) { groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return } } Intent(this, DisappearingMessagesActivity::class.java) @@ -1663,7 +1723,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onReactionLongClicked(messageId: MessageId, emoji: String?) { - if (viewModel.recipient?.isGroupRecipient == true) { + if (viewModel.recipient?.isGroupOrCommunityRecipient == true) { val isUserModerator = viewModel.openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey) @@ -1706,19 +1766,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) } - private fun processMessageRequestApproval() { - if (isIncomingMessageRequestThread()) { - acceptMessageRequest() - } else if (viewModel.recipient?.isApproved == false) { - // edge case for new outgoing thread on new recipient without sending approval messages - viewModel.setRecipientApproved() - } - } - private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset - processMessageRequestApproval() + viewModel.beforeSendingTextOnlyMessage() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) @@ -1771,7 +1822,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset - processMessageRequestApproval() + viewModel.beforeSendingAttachments() // Create the message val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp @@ -2292,7 +2343,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendScreenshotNotification() { val recipient = viewModel.recipient ?: return - if (recipient.isGroupRecipient) return + if (recipient.isGroupOrCommunityRecipient) return val kind = DataExtractionNotification.Kind.Screenshot() val message = DataExtractionNotification(kind) MessageSender.send(message, recipient.address) @@ -2300,7 +2351,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendMediaSavedNotification() { val recipient = viewModel.recipient ?: return - if (recipient.isGroupRecipient) { return } + if (recipient.isGroupOrCommunityRecipient) { return } val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) 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 5ecddd21476..8ab5c32f521 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 @@ -2,47 +2,52 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application import android.content.Context +import android.view.MenuItem import android.widget.Toast +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.goterl.lazysodium.utils.KeyPair import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.getType +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.audio.AudioSlidePlayer +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper +import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.ReactionDatabase -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 import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.repository.ConversationRepository @@ -53,19 +58,22 @@ class ConversationViewModel( val edKeyPair: KeyPair?, private val application: Application, private val repository: ConversationRepository, - private val storage: Storage, + private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, + private val groupDb: GroupDatabase, private val threadDb: ThreadDatabase, private val reactionDb: ReactionDatabase, private val lokiMessageDb: LokiMessageDatabase, - private val textSecurePreferences: TextSecurePreferences + private val textSecurePreferences: TextSecurePreferences, + private val configFactory: ConfigFactory, + private val groupManagerV2: GroupManagerV2, ) : ViewModel() { val showSendAfterApprovalText: Boolean get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false - private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) - val uiState: StateFlow = _uiState + private val _uiState = MutableStateFlow(ConversationUiState()) + val uiState: StateFlow get() = _uiState private val _dialogsState = MutableStateFlow(DialogsState()) val dialogsState: StateFlow = _dialogsState @@ -82,8 +90,7 @@ class ConversationViewModel( _isAdmin.value = when(conversationType) { // for Groups V2 MessageType.GROUPS_V2 -> { - //todo GROUPS V2 add logic where code is commented to determine if user is an admin - false // FANCHAO - properly set up admin for groups v2 here + configFactory.getGroup(AccountId(conversation.address.serialize()))?.hasAdminKey() == true } // for legacy groups, check if the user created the group @@ -115,13 +122,24 @@ class ConversationViewModel( val blindedRecipient: Recipient? get() = _recipient.value?.let { recipient -> when { - recipient.isOpenGroupOutboxRecipient -> recipient - recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient) + recipient.isCommunityOutboxRecipient -> recipient + recipient.isCommunityInboxRecipient -> repository.maybeGetBlindedRecipient(recipient) else -> null } } - private var communityWriteAccessJob: Job? = null + /** + * The admin who invites us to this group(v2) conversation. + * + * null if this convo is not a group(v2) conversation, or error getting the info + */ + val invitingAdmin: Recipient? + get() { + val recipient = recipient ?: return null + if (!recipient.isGroupV2Recipient) return null + + return repository.getInvitingAdmin(threadId) + } private var _openGroup: RetrieveOnce = RetrieveOnce { storage.getOpenGroup(threadId) @@ -141,7 +159,7 @@ class ConversationViewModel( val isMessageRequestThread : Boolean get() { val recipient = recipient ?: return false - return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved + return !recipient.isLocalNumber && !recipient.isLegacyGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved } val canReactToMessages: Boolean @@ -155,35 +173,97 @@ class ConversationViewModel( ) init { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(Dispatchers.Default) { repository.recipientUpdateFlow(threadId) .collect { recipient -> - if (recipient == null && _uiState.value.conversationExists) { - _uiState.update { it.copy(conversationExists = false) } + _uiState.update { + it.copy( + shouldExit = recipient == null, + showInput = shouldShowInput(recipient), + enableInputMediaControls = shouldEnableInputMediaControls(recipient), + messageRequestState = buildMessageRequestState(recipient), + ) } } } + } - // listen to community write access updates from this point - communityWriteAccessJob?.cancel() - communityWriteAccessJob = viewModelScope.launch { - OpenGroupManager.getCommunitiesWriteAccessFlow() - .map { - if(openGroup?.groupId != null) - it[openGroup?.groupId] - else null - } - .filterNotNull() - .collect{ - // update our community object - _openGroup.updateTo(openGroup?.copy(canWrite = it)) - // when we get an update on the write access of a community - // we need to update the input text accordingly - _uiState.update { state -> - state.copy(hideInputBar = shouldHideInputBar()) - } + /** + * Determines if the input media controls should be enabled. + * + * Normally we will show the input media controls, only in these situations we hide them: + * 1. First time we send message to a person. + * Since we haven't been approved by them, we can't send them any media, only text + */ + private fun shouldEnableInputMediaControls(recipient: Recipient?): Boolean { + if (recipient != null && + (recipient.is1on1 && !recipient.isLocalNumber) && + !recipient.hasApprovedMe()) { + return false + } + + return true + } + + /** + * Determines if the input bar should be shown. + * + * For these situations we hide the input bar: + * 1. The user has been kicked from a group(v2), OR + * 2. The legacy group is inactive, OR + * 3. The community chat is read only + */ + private fun shouldShowInput(recipient: Recipient?): Boolean { + return when { + recipient?.isGroupV2Recipient == true -> !repository.isGroupReadOnly(recipient) + recipient?.isLegacyGroupRecipient == true -> { + groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true + } + openGroup != null -> openGroup?.canWrite == true + else -> true + } + } + + private fun buildMessageRequestState(recipient: Recipient?): MessageRequestUiState { + // The basic requirement of showing a message request is: + // 1. The other party has not been approved by us, AND + // 2. We haven't sent a message to them before (if we do, we would be the one requesting permission), AND + // 3. We have received message from them AND + // 4. The type of conversation supports message request (only 1to1 and groups v2) + + if ( + recipient != null && + + // Req 1: we haven't approved the other party + (!recipient.isApproved && !recipient.isLocalNumber) && + + // Req 4: the type of conversation supports message request + (recipient.is1on1 || recipient.isGroupV2Recipient) && + + // Req 2: we haven't sent a message to them before + !threadDb.getLastSeenAndHasSent(threadId).second() && + + // Req 3: we have received message from them + threadDb.getMessageCount(threadId) > 0 + ) { + + return MessageRequestUiState.Visible( + acceptButtonText = if (recipient.isGroupOrCommunityRecipient) { + R.string.messageRequestGroupInviteDescription + } else { + R.string.messageRequestsAcceptDescription + }, + // You can block a 1to1 conversation, or a normal groups v2 conversation + showBlockButton = recipient.is1on1 || recipient.isGroupV2Recipient, + declineButtonText = if (recipient.isGroupV2Recipient) { + R.string.delete + } else { + R.string.decline } + ) } + + return MessageRequestUiState.Invisible } override fun onCleared() { @@ -214,16 +294,17 @@ class ConversationViewModel( } fun block() { - val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") - if (recipient.isContactRecipient) { - repository.setBlocked(recipient, true) + // inviting admin will be true if this request is a closed group message request + val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action") + if (recipient.isContactRecipient || recipient.isGroupV2Recipient) { + repository.setBlocked(threadId, recipient, true) } } fun unblock() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") if (recipient.isContactRecipient) { - repository.setBlocked(recipient, false) + repository.setBlocked(threadId, recipient, false) } } @@ -514,13 +595,12 @@ class ConversationViewModel( } private fun markAsDeletedForEveryoneGroupsV2(data: DeleteForEveryoneDialogData){ - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(Dispatchers.Default) { // show a loading indicator _uiState.update { it.copy(showLoader = true) } - //todo GROUPS V2 - uncomment below and use Fanchao's method to delete a group V2 try { - //repository.callMethodFromFanchao(threadId, recipient, data.messages) + repository.deleteGroupV2MessagesRemotely(recipient!!, data.messages) // the repo will handle the internal logic (calling `/delete` on the swarm // and sending 'GroupUpdateDeleteMemberContentMessage' @@ -542,7 +622,7 @@ class ConversationViewModel( ).show() } } catch (e: Exception) { - Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + Log.e("Loki", "FAILED TO delete messages ${data.messages}", e) // failed to delete - show a toast and get back on the modal withContext(Dispatchers.Main) { Toast.makeText( @@ -658,19 +738,36 @@ class ConversationViewModel( fun acceptMessageRequest() = viewModelScope.launch { val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action") + val currentState = _uiState.value.messageRequestState as? MessageRequestUiState.Visible + ?: return@launch Log.w("Loki", "Current state was not visible for accept message request action") + + _uiState.update { + it.copy(messageRequestState = MessageRequestUiState.Pending(currentState)) + } + repository.acceptMessageRequest(threadId, recipient) .onSuccess { _uiState.update { - it.copy(isMessageRequestAccepted = true) + it.copy(messageRequestState = MessageRequestUiState.Invisible) } } .onFailure { - Log.w("", "Failed to accept message request: $it") + Log.w("Loki", "Couldn't accept message request due to error", it) + + _uiState.update { state -> + state.copy(messageRequestState = currentState) + } } } - fun declineMessageRequest() { - repository.declineMessageRequest(threadId) + fun declineMessageRequest() = viewModelScope.launch { + repository.declineMessageRequest(threadId, recipient!!) + .onSuccess { + _uiState.update { it.copy(shouldExit = true) } + } + .onFailure { + Log.w("Loki", "Couldn't decline message request due to error", it) + } } private fun showMessage(message: String) { @@ -715,6 +812,25 @@ class ConversationViewModel( attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) } + fun beforeSendingTextOnlyMessage() { + implicitlyApproveRecipient() + } + + fun beforeSendingAttachments() { + implicitlyApproveRecipient() + } + + private fun implicitlyApproveRecipient() { + val recipient = recipient + + if (uiState.value.messageRequestState is MessageRequestUiState.Visible) { + acceptMessageRequest() + } else if (recipient?.isApproved == false) { + // edge case for new outgoing thread on new recipient without sending approval messages + repository.setApproved(recipient, true) + } + } + fun onCommand(command: Commands) { when (command) { is Commands.ShowOpenUrlDialog -> { @@ -778,6 +894,37 @@ class ConversationViewModel( } } + fun onOptionItemSelected( + // This must be the context of the activity as requirement from ConversationMenuHelper + context: Context, + item: MenuItem + ): Boolean { + val recipient = recipient ?: return false + + val inProgress = ConversationMenuHelper.onOptionItemSelected( + context = context, + item = item, + thread = recipient, + threadID = threadId, + factory = configFactory, + storage = storage, + groupManager = groupManagerV2, + ) + + if (inProgress != null) { + viewModelScope.launch { + _uiState.update { it.copy(showLoader = true) } + try { + inProgress.receive() + } finally { + _uiState.update { it.copy(showLoader = false) } + } + } + } + + return true + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -789,12 +936,17 @@ class ConversationViewModel( @Assisted private val edKeyPair: KeyPair?, private val application: Application, private val repository: ConversationRepository, - private val storage: Storage, + private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, + private val groupDb: GroupDatabase, private val threadDb: ThreadDatabase, private val reactionDb: ReactionDatabase, + @ApplicationContext + private val context: Context, private val lokiMessageDb: LokiMessageDatabase, - private val textSecurePreferences: TextSecurePreferences + private val textSecurePreferences: TextSecurePreferences, + private val configFactory: ConfigFactory, + private val groupManagerV2: GroupManagerV2, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -805,10 +957,13 @@ class ConversationViewModel( repository = repository, storage = storage, messageDataProvider = messageDataProvider, + groupDb = groupDb, threadDb = threadDb, reactionDb = reactionDb, lokiMessageDb = lokiMessageDb, - textSecurePreferences = textSecurePreferences + textSecurePreferences = textSecurePreferences, + configFactory = configFactory, + groupManagerV2 = groupManagerV2, ) as T } } @@ -850,12 +1005,25 @@ data class UiMessage(val id: Long, val message: String) data class ConversationUiState( val uiMessages: List = emptyList(), - val isMessageRequestAccepted: Boolean? = null, - val conversationExists: Boolean, - val hideInputBar: Boolean = false, + val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible, + val shouldExit: Boolean = false, + val showInput: Boolean = true, + val enableInputMediaControls: Boolean = true, val showLoader: Boolean = false ) +sealed interface MessageRequestUiState { + data object Invisible : MessageRequestUiState + + data class Pending(val prevState: Visible) : MessageRequestUiState + + data class Visible( + @StringRes val acceptButtonText: Int, + val showBlockButton: Boolean, + @StringRes val declineButtonText: Int, + ) : MessageRequestUiState +} + data class RetrieveOnce(val retrieval: () -> T?) { private var triedToRetrieve: Boolean = false private var _value: T? = null 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 deleted file mode 100644 index 58c5536248a..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2 - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentDeleteMessageBottomSheetBinding -import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.util.UiModeUtilities -import javax.inject.Inject - -@AndroidEntryPoint -class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener { - - @Inject - lateinit var contactDatabase: SessionContactDatabase - - lateinit var recipient: Recipient - private lateinit var binding: FragmentDeleteMessageBottomSheetBinding - val contact by lazy { - val senderId = recipient.address.serialize() - // this dialog won't show for open group contacts - contactDatabase.getContactWithAccountID(senderId) - ?.displayName(Contact.ContactContext.REGULAR) - } - - var onDeleteForMeTapped: (() -> Unit?)? = null - var onDeleteForEveryoneTapped: (() -> Unit)? = null - var onCancelTapped: (() -> Unit)? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentDeleteMessageBottomSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onClick(v: View?) { - when (v) { - binding.deleteForMeTextView -> onDeleteForMeTapped?.invoke() - binding.deleteForEveryoneTextView -> onDeleteForEveryoneTapped?.invoke() - binding.cancelTextView -> onCancelTapped?.invoke() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - if (!this::recipient.isInitialized) { - return dismiss() - } - if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { - binding.deleteForEveryoneTextView.text = - resources.getString(R.string.clearMessagesForEveryone, contact) - } - binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient - binding.deleteForMeTextView.setOnClickListener(this) - binding.deleteForEveryoneTextView.setOnClickListener(this) - binding.cancelTextView.setOnClickListener(this) - } - - override fun onStart() { - super.onStart() - val window = dialog?.window ?: return - window.setDimAmount(0.6f) - } -} \ No newline at end of file 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 bd491bbe705..038210f8ff9 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 @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -57,11 +56,10 @@ 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.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.CarouselNextButton import org.thoughtcrime.securesms.ui.CarouselPrevButton @@ -88,7 +86,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 ac181b98ff0..4dd0b9a89db 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 @@ -1,54 +1,48 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs import android.app.Dialog -import android.graphics.Typeface import android.os.Bundle -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.style.StyleSpan import androidx.fragment.app.DialogFragment import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R -import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.jobs.AttachmentDownloadJob +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.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload 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 accountID = recipient.address.toString() - val contact = contactDB.getContactWithAccountID(accountID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID - title(getString(R.string.attachmentsAutoDownloadModalTitle)) val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription) - .put(CONVERSATION_NAME_KEY, recipient.toShortString()) + .put(CONVERSATION_NAME_KEY, threadRecipient.toShortString()) .format() text(explanation) - button(R.string.download, R.string.AccessibilityId_download) { trust() } + button(R.string.download, R.string.AccessibilityId_download) { + setAutoDownload() + } + cancelButton { dismiss() } } - private fun trust() { - val accountID = recipient.address.toString() - val contact = contactDB.getContactWithAccountID(accountID) ?: return - val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) - contactDB.setContactIsTrusted(contact, true, threadID) - JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) - dismiss() + private fun setAutoDownload() { + storage.setAutoDownloadAttachments(threadRecipient, true) + JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index 21405b26c5c..f2bd9fd222c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -9,19 +9,26 @@ import android.text.SpannableStringBuilder import android.text.style.StyleSpan import android.widget.Toast import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope import com.squareup.phrase.Phrase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject /** Shown upon tapping an open group invitation. */ class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() { + @Inject + lateinit var storage: StorageProtocol + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(resources.getString(R.string.communityJoin)) val explanation = Phrase.from(context, R.string.communityJoinDescription).put(COMMUNITY_NAME_KEY, name).format() @@ -40,15 +47,23 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D private fun join() { val openGroup = OpenGroupUrlParser.parseUrl(url) val activity = requireActivity() - ThreadUtils.queue { + lifecycleScope.launch { try { - openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) } - MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) + withContext(Dispatchers.Default) { + OpenGroupManager.add( + server = openGroup.server, + room = openGroup.room, + publicKey = openGroup.serverPublicKey, + context = activity + ) + + storage.onOpenGroupAdded(openGroup.server, openGroup.room) + } } catch (e: Exception) { Toast.makeText(activity, R.string.communityErrorDescription, Toast.LENGTH_SHORT).show() } } + dismiss() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index cd911b2ace6..fe86f8d3827 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -60,8 +60,13 @@ class InputBar @JvmOverloads constructor( var delegate: InputBarDelegate? = null var quote: MessageRecord? = null var linkPreview: LinkPreview? = null - var showInput: Boolean = true - set(value) { field = value; showOrHideInputIfNeeded() } + private var showInput: Boolean = true + set(value) { + if (field != value) { + field = value + showOrHideInputIfNeeded() + } + } var showMediaControls: Boolean = true set(value) { field = value @@ -252,20 +257,20 @@ class InputBar @JvmOverloads constructor( } private fun showOrHideInputIfNeeded() { - if (showInput) { - setOf( binding.inputBarEditText, attachmentsButton ).forEach { it.isVisible = true } - microphoneButton.isVisible = text.isEmpty() - sendButton.isVisible = text.isNotEmpty() - } else { + if (!showInput) { cancelQuoteDraft() cancelLinkPreviewDraft() - val views = setOf( binding.inputBarEditText, attachmentsButton, microphoneButton, sendButton ) - views.forEach { it.isVisible = false } } + + binding.inputBarEditText.isVisible = showInput + attachmentsButton.isVisible = showInput + microphoneButton.isVisible = showInput && text.isEmpty() + sendButton.isVisible = showInput && text.isNotEmpty() } private fun showOrHideMediaControlsIfNeeded() { - setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls } + attachmentsButton.snIsEnabled = showMediaControls + microphoneButton.snIsEnabled = showMediaControls } fun addTextChangedListener(listener: (String) -> Unit) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index e3e5df04580..b0ea0d399e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -85,10 +85,13 @@ class MentionViewModel( } val memberIDs = when { - recipient.isClosedGroupRecipient -> { + recipient.isLegacyGroupRecipient -> { groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false) .map { it.serialize() } } + recipient.isGroupV2Recipient -> { + storage.getMembers(recipient.address.serialize()).map { it.sessionId } + } recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20) recipient.isContactRecipient -> listOf(recipient.address.serialize()) 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 ea265bdbbf8..a4c2685dc3d 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,11 +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.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.IdPrefix -import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord @@ -38,7 +37,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! + val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString @@ -64,7 +63,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText // Copy Account ID menu.findItem(R.id.menu_context_copy_public_key).isVisible = - (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) + (thread.isGroupOrCommunityRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) // Message detail menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1 // Resend 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 0997db18717..2aeebacc279 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 @@ -18,7 +18,16 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import com.squareup.phrase.Phrase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID @@ -26,6 +35,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString @@ -34,9 +44,11 @@ import org.thoughtcrime.securesms.calls.WebRtcCallActivity 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.ConfigFactory 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.EditGroupActivity +import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity +import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.media.MediaOverviewActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity @@ -46,15 +58,15 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.findActivity import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.BitmapUtil -import java.io.IOException object ConversationMenuHelper { - + fun onPrepareOptionsMenu( menu: Menu, inflater: MenuInflater, thread: Recipient, - context: Context + context: Context, + configFactory: ConfigFactory, ) { // Prepare menu.clear() @@ -62,7 +74,7 @@ object ConversationMenuHelper { // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isCommunity && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { + if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyGroupRecipient || thread.isLocalNumber)) { inflater.inflate(R.menu.menu_conversation_expiration, menu) } // One-on-one chat menu allows copying the account id @@ -77,10 +89,21 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_block, menu) } } - // Closed group menu (options that should only be present in closed groups) - if (thread.isClosedGroupRecipient) { - inflater.inflate(R.menu.menu_conversation_closed_group, menu) + // (Legacy) Closed group menu (options that should only be present in closed groups) + if (thread.isLegacyGroupRecipient) { + inflater.inflate(R.menu.menu_conversation_legacy_group, menu) + } + + // Groups v2 menu + if (thread.isGroupV2Recipient) { + val hasAdminKey = configFactory.withUserConfigs { it.userGroups.getClosedGroup(thread.address.serialize())?.hasAdminKey() } + if (hasAdminKey == true) { + inflater.inflate(R.menu.menu_conversation_groups_v2_admin, menu) + } + + inflater.inflate(R.menu.menu_conversation_groups_v2, menu) } + // Open group menu if (isCommunity) { inflater.inflate(R.menu.menu_conversation_open_group, menu) @@ -92,7 +115,7 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_unmuted, menu) } - if (thread.isGroupRecipient && !thread.isMuted) { + if (thread.isGroupOrCommunityRecipient && !thread.isMuted) { inflater.inflate(R.menu.menu_conversation_notification_settings, menu) } @@ -134,7 +157,21 @@ object ConversationMenuHelper { }) } - fun onOptionItemSelected(context: Context, item: MenuItem, thread: Recipient): Boolean { + /** + * Handle the selected option + * + * @return An asynchronous channel that can be used to wait for the action to complete. Null if + * the action does not require waiting. + */ + fun onOptionItemSelected( + context: Context, + item: MenuItem, + thread: Recipient, + threadID: Long, + factory: ConfigFactory, + storage: StorageProtocol, + groupManager: GroupManagerV2, + ): ReceiveChannel? { when (item.itemId) { R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_search -> { search(context) } @@ -145,15 +182,16 @@ object ConversationMenuHelper { R.id.menu_block_delete -> { blockAndDelete(context, thread) } R.id.menu_copy_account_id -> { copyAccountID(context, thread) } R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } - R.id.menu_edit_group -> { editClosedGroup(context, thread) } - R.id.menu_leave_group -> { leaveClosedGroup(context, thread) } + R.id.menu_edit_group -> { editGroup(context, thread) } + R.id.menu_leave_group -> { return leaveGroup(context, thread, threadID, factory, storage, groupManager) } R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } R.id.menu_unmute_notifications -> { unmute(context, thread) } R.id.menu_mute_notifications -> { mute(context, thread) } R.id.menu_notification_settings -> { setNotifyType(context, thread) } R.id.menu_call -> { call(context, thread) } } - return true + + return null } private fun showAllMedia(context: Context, thread: Recipient) { @@ -221,7 +259,7 @@ object ConversationMenuHelper { } } if (icon == null) { - icon = IconCompat.createWithResource(context, if (thread.isGroupRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut) + icon = IconCompat.createWithResource(context, if (thread.isGroupOrCommunityRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut) } return icon } @@ -278,34 +316,105 @@ object ConversationMenuHelper { listener.copyOpenGroupUrl(thread) } - private fun editClosedGroup(context: Context, thread: Recipient) { - if (!thread.isClosedGroupRecipient) { return } - val intent = Intent(context, EditClosedGroupActivity::class.java) - val groupID: String = thread.address.toGroupString() - intent.putExtra(groupIDKey, groupID) - context.startActivity(intent) + private fun editGroup(context: Context, thread: Recipient) { + when { + thread.isGroupV2Recipient -> { + context.startActivity(EditGroupActivity.createIntent(context, thread.address.serialize())) + } + + thread.isLegacyGroupRecipient -> { + val intent = Intent(context, EditLegacyGroupActivity::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 } + fun leaveGroup( + context: Context, + thread: Recipient, + threadID: Long, + configFactory: ConfigFactory, + storage: StorageProtocol, + groupManager: GroupManagerV2, + ): ReceiveChannel? { + when { + thread.isLegacyGroupRecipient -> { + val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() + val admins = group.admins + val accountID = TextSecurePreferences.getLocalNumber(context) + val isCurrentUserAdmin = admins.any { it.toString() == accountID } + + confirmAndLeaveGroup( + context = context, + groupName = group.title, + isAdmin = isCurrentUserAdmin, + threadID = threadID, + storage = storage, + doLeave = { + val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() + + check(DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)) { + "Invalid group public key" + } + MessageSender.leave(groupPublicKey, notifyUser = false) + } + ) + } + + thread.isGroupV2Recipient -> { + val accountId = AccountId(thread.address.serialize()) + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return null + val name = configFactory.withGroupConfigs(accountId) { + it.groupInfo.getName() + } ?: group.name + + val channel = Channel() + + confirmAndLeaveGroup( + context = context, + groupName = name, + isAdmin = group.hasAdminKey(), + threadID = threadID, + storage = storage, + doLeave = { + try { + groupManager.leaveGroup(accountId, true) + } finally { + channel.send(Unit) + } + } + ) - val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() - val admins = group.admins - val accountID = TextSecurePreferences.getLocalNumber(context) - val isCurrentUserAdmin = admins.any { it.toString() == accountID } - val message = if (isCurrentUserAdmin) { + return channel + } + } + + return null + } + + private fun confirmAndLeaveGroup( + context: Context, + groupName: String, + isAdmin: Boolean, + threadID: Long, + storage: StorageProtocol, + doLeave: suspend () -> Unit, + ) { + val message = if (isAdmin) { Phrase.from(context, R.string.groupDeleteDescription) - .put(GROUP_NAME_KEY, group.title) + .put(GROUP_NAME_KEY, groupName) .format() } else { Phrase.from(context, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, group.title) + .put(GROUP_NAME_KEY, groupName) .format() } fun onLeaveFailed() { val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) - .put(GROUP_NAME_KEY, group.title) + .put(GROUP_NAME_KEY, groupName) .format().toString() Toast.makeText(context, txt, Toast.LENGTH_LONG).show() } @@ -314,15 +423,20 @@ object ConversationMenuHelper { title(R.string.groupLeave) text(message) dangerButton(R.string.leave) { - try { - val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() - val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - - if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false) - else onLeaveFailed() - } catch (e: Exception) { - onLeaveFailed() + GlobalScope.launch(Dispatchers.Default) { + try { + // Cancel any outstanding jobs + storage.cancelPendingMessageSendJobs(threadID) + + doLeave() + } catch (e: Exception) { + Log.e("Conversation", "Error leaving group", e) + withContext(Dispatchers.Main) { + onLeaveFailed() + } + } } + } button(R.string.cancel) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 08a06407c54..8723be7b913 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -4,7 +4,6 @@ import android.Manifest import android.content.Context import android.content.Intent import android.util.AttributeSet -import android.util.Log import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout @@ -12,13 +11,13 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewControlMessageBinding import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences @@ -67,6 +66,7 @@ class ControlMessageView : LinearLayout { binding.expirationTimerView.isGone = true binding.followSetting.isGone = true var messageBody: CharSequence = message.getDisplayBody(context) + binding.root.contentDescription = null binding.textView.text = messageBody when { @@ -76,7 +76,7 @@ class ControlMessageView : LinearLayout { val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) - if (threadRecipient?.isClosedGroupRecipient == true) { + if (threadRecipient?.isGroupRecipient == true) { expirationTimerView.setTimerIcon() } else { expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) @@ -85,7 +85,7 @@ class ControlMessageView : LinearLayout { followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled && !message.isOutgoing && message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) - && threadRecipient?.isGroupRecipient != true + && threadRecipient?.isGroupOrCommunityRecipient != true if (followSetting.isVisible) { binding.controlContentView.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } @@ -202,6 +202,12 @@ class ControlMessageView : LinearLayout { } } } + message.isGroupUpdateMessage -> { + val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body) + if (updateMessageData?.isGroupErrorQuitKind() == true) { + binding.textView.setTextColor(context.getColorFromAttr(R.attr.danger)) + } + } } binding.textView.isGone = message.isCallLog 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 00000000000..72e37b5dddb --- /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 com.squareup.phrase.Phrase +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.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY +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.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.audio + AttachmentType.DOCUMENT -> R.string.document + AttachmentType.IMAGE -> R.string.image + AttachmentType.VIDEO -> R.string.video + } + + val text = Phrase.from(context, R.string.attachmentsTapToDownload) + .put(FILE_TYPE_KEY, context.getString(stringRes).lowercase(Locale.ROOT)) + .format() + + binding.pendingDownloadIcon.setColorFilter(textColor) + binding.pendingDownloadSize.text = attachment.displaySize() + binding.pendingDownloadTitle.text = text + } + // endregion + + // region Interaction + fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) { + if (!storage.shouldAutoDownloadAttachments(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 7d1dc625f68..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt +++ /dev/null @@ -1,56 +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 com.squareup.phrase.Phrase -import network.loki.messenger.R -import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding -import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog -import org.thoughtcrime.securesms.util.ActivityDispatcher - -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.audio - AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.files - AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media - } - val iconDrawable = ContextCompat.getDrawable(context,iconRes)!! - iconDrawable.mutate().setTint(textColor) - - val text = Phrase.from(context, R.string.attachmentsTapToDownload) - .put(FILE_TYPE_KEY, context.getString(stringRes)) - .format() - binding.untrustedAttachmentTitle.text = text - - 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 d4949347a97..a99e7b60d17 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 @@ -23,6 +23,7 @@ import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr @@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor @@ -61,7 +61,6 @@ class VisibleMessageContentView : ConstraintLayout { glide: RequestManager = Glide.with(this), thread: Recipient, searchQuery: String? = null, - contactIsTrusted: Boolean = true, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, suppressThumbnails: Boolean = false ) { @@ -71,7 +70,9 @@ class VisibleMessageContentView : ConstraintLayout { binding.contentParent.mainColor = color binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) - 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() @@ -85,7 +86,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 @@ -100,9 +100,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 @@ -140,6 +140,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) } @@ -147,10 +148,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) @@ -159,26 +161,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( @@ -196,13 +210,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() } } } @@ -239,7 +262,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 98e864e4721..38ed6706735 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 @@ -16,7 +16,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -61,7 +60,6 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping @@ -155,14 +153,14 @@ class VisibleMessageView : FrameLayout { replyDisabled = message.isOpenGroupInvitation val threadID = message.threadId val thread = threadDb.getRecipientForThreadId(threadID) ?: return - val isGroupThread = thread.isGroupRecipient + val isGroupThread = thread.isGroupOrCommunityRecipient val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread) val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread) // Show profile picture and sender name if this is a group thread AND the message is incoming binding.moderatorIconImageView.isVisible = false binding.profilePictureView.visibility = when { - thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE - thread.isGroupRecipient -> View.INVISIBLE + thread.isGroupOrCommunityRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE + thread.isGroupOrCommunityRecipient -> View.INVISIBLE else -> View.GONE } @@ -261,7 +259,6 @@ class VisibleMessageView : FrameLayout { 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/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index c1d69879044..8215eecd9b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -34,7 +34,7 @@ object ResendMessageUtilities { message.text = messageRecord.body } message.sentTimestamp = messageRecord.timestamp - if (recipient.isGroupRecipient) { + if (recipient.isGroupOrCommunityRecipient) { message.groupPublicKey = recipient.address.toGroupString() } else { message.recipient = messageRecord.recipient.address.serialize() diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java index 4db46a3abc4..e2fe41b6259 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -35,6 +35,12 @@ import java.io.IOException; +import kotlin.Unit; +import kotlinx.coroutines.channels.BufferOverflow; +import kotlinx.coroutines.flow.MutableSharedFlow; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.SharedFlowKt; + /** * Utility class for working with identity keys. * @@ -56,6 +62,8 @@ public class IdentityKeyUtil { public static final String LOKI_SEED = "loki_seed"; public static final String HAS_MIGRATED_KEY = "has_migrated_keys"; + public static final MutableSharedFlow CHANGES = SharedFlowKt.MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST); + private static SharedPreferences getSharedPreferences(Context context) { return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0); } @@ -158,9 +166,11 @@ public static void save(Context context, String key, String value) { } if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences"); + CHANGES.tryEmit(Unit.INSTANCE); } public static void delete(Context context, String key) { context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit(); + CHANGES.tryEmit(Unit.INSTANCE); } } 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 f95d62db11c..d4c455308cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -4,8 +4,13 @@ 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.AccountId import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +typealias ConfigVariant = String + class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { companion object { @@ -20,9 +25,19 @@ 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 CONTACTS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CONTACTS.name + val USER_GROUPS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.GROUPS.name + val USER_PROFILE_VARIANT: ConfigVariant = SharedConfigMessage.Kind.USER_PROFILE.name + val CONVO_INFO_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name + + val KEYS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name + val INFO_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name + val MEMBER_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name } - fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) { + fun storeConfig(variant: ConfigVariant, publicKey: String, data: ByteArray, timestamp: Long) { val db = writableDatabase val contentValues = contentValuesOf( VARIANT to variant, @@ -33,7 +48,50 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) } - fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? { + fun deleteGroupConfigs(closedGroupId: AccountId) { + 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: ConfigVariant, publicKey: String): ByteArray? { val db = readableDatabase val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) return query?.use { cursor -> @@ -43,7 +101,7 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co } } - fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long { + fun retrieveConfigLastUpdateTimestamp(variant: ConfigVariant, publicKey: String): Long { return readableDatabase .query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey), null, null, null) ?.use { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt index 013bbf5cb52..6af3048e65b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt @@ -5,7 +5,7 @@ import android.content.Context import android.database.Cursor import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata -import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX +import org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -29,7 +29,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """ INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} - WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%' + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$LEGACY_CLOSED_GROUP_PREFIX%' AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) """.trimIndent() @@ -37,7 +37,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """ INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} - WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$LEGACY_CLOSED_GROUP_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%' AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 6e1f72c5680..f5c71f1676e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -32,6 +32,13 @@ import java.util.LinkedList; import java.util.List; +/** + * @deprecated This database table management is only used for + * legacy group management. It is not used in groupv2. For group v2 data, you generally need + * to query config system directly. The Storage class may also be more up-to-date. + * + */ +@Deprecated public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProtocol { @SuppressWarnings("unused") diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 18dd42818d9..e9c13ca52f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -3,26 +3,36 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE +import org.intellij.lang.annotations.Language +import org.json.JSONArray +import org.session.libsession.database.ServerHashToMessageId import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.util.asSequence class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { companion object { - private val messageIDTable = "loki_message_friend_request_database" - private val messageThreadMappingTable = "loki_message_thread_mapping_database" - private val errorMessageTable = "loki_error_message_database" - private val messageHashTable = "loki_message_hash_database" - private val smsHashTable = "loki_sms_hash_database" - private val mmsHashTable = "loki_mms_hash_database" - private val messageID = "message_id" - private val serverID = "server_id" - private val friendRequestStatus = "friend_request_status" - private val threadID = "thread_id" - private val errorMessage = "error_message" - private val messageType = "message_type" - private val serverHash = "server_hash" + private const val messageIDTable = "loki_message_friend_request_database" + private const val messageThreadMappingTable = "loki_message_thread_mapping_database" + private const val errorMessageTable = "loki_error_message_database" + private const val messageHashTable = "loki_message_hash_database" + private const val smsHashTable = "loki_sms_hash_database" + private const val mmsHashTable = "loki_mms_hash_database" + const val groupInviteTable = "loki_group_invites" + + private const val groupInviteDeleteTrigger = "group_invite_delete_trigger" + + private const val messageID = "message_id" + private const val serverID = "server_id" + private const val friendRequestStatus = "friend_request_status" + private const val threadID = "thread_id" + private const val errorMessage = "error_message" + private const val messageType = "message_type" + private const val serverHash = "server_hash" + const val invitingSessionId = "inviting_session_id" + @JvmStatic val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" @JvmStatic @@ -39,6 +49,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" @JvmStatic val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" + @JvmStatic + val createGroupInviteTableCommand = "CREATE TABLE IF NOT EXISTS $groupInviteTable ($threadID INTEGER PRIMARY KEY, $invitingSessionId STRING);" + @JvmStatic + val createThreadDeleteTrigger = "CREATE TRIGGER IF NOT EXISTS $groupInviteDeleteTrigger AFTER DELETE ON ${ThreadDatabase.TABLE_NAME} BEGIN DELETE FROM $groupInviteTable WHERE $threadID = OLD.${ThreadDatabase.ID}; END;" const val SMS_TYPE = 0 const val MMS_TYPE = 1 @@ -224,6 +238,55 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab } } + fun getSendersForHashes(threadId: Long, hashes: Set): List { + @Language("RoomSql") + val query = """ + WITH + sender_hash_mapping AS ( + SELECT + sms_hash_table.$serverHash AS hash, + sms.${MmsSmsColumns.ID} AS message_id, + sms.${MmsSmsColumns.ADDRESS} AS sender, + sms.${SmsDatabase.TYPE} AS type, + true AS is_sms + FROM $smsHashTable sms_hash_table + LEFT OUTER JOIN ${SmsDatabase.TABLE_NAME} sms ON sms_hash_table.${messageID} = sms.${MmsSmsColumns.ID} + WHERE sms.${MmsSmsColumns.THREAD_ID} = :threadId + + UNION ALL + + SELECT + mms_hash_table.$serverHash, + mms.${MmsSmsColumns.ID}, + mms.${MmsSmsColumns.ADDRESS}, + mms.${MmsDatabase.MESSAGE_TYPE}, + false + FROM $mmsHashTable mms_hash_table + LEFT OUTER JOIN ${MmsDatabase.TABLE_NAME} mms ON mms_hash_table.${messageID} = mms.${MmsSmsColumns.ID} + WHERE mms.${MmsSmsColumns.THREAD_ID} = :threadId + ) + SELECT * FROM sender_hash_mapping + WHERE hash IN (SELECT value FROM json_each(:hashes)) + """.trimIndent() + + val result = databaseHelper.readableDatabase.query(query, arrayOf(threadId, JSONArray(hashes).toString())) + .use { cursor -> + cursor.asSequence() + .map { + ServerHashToMessageId( + serverHash = cursor.getString(0), + messageId = cursor.getLong(1), + sender = cursor.getString(2), + isSms = cursor.getInt(4) == 1, + isOutgoing = MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(3)) + ) + } + .toList() + } + + return result + } + fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull { databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> cursor.getString(serverHash) @@ -255,6 +318,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab ) } + fun addGroupInviteReferrer(groupThreadId: Long, referrerSessionId: String) { + val contentValues = ContentValues(2).apply { + put(threadID, groupThreadId) + put(invitingSessionId, referrerSessionId) + } + databaseHelper.writableDatabase.insertOrUpdate( + groupInviteTable, contentValues, "$threadID = ?", arrayOf(groupThreadId.toString()) + ) + } + + fun groupInviteReferrer(groupThreadId: Long): String? { + return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor -> + cursor.getString(invitingSessionId) + } + } + + fun deleteGroupInviteReferrer(groupThreadId: Long) { + databaseHelper.writableDatabase.delete( + groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString()) + ) + } private fun getMessageTables(mms: Boolean) = sequenceOf( getMessageTable(mms), messageHashTable 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 63db0c66ba1..3f44588393c 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/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index 2377ccf3017..5622807127d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -14,7 +14,6 @@ import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.util.SqlUtil; @@ -55,6 +54,8 @@ public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException; + public abstract String getTypeColumn(); + public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { try { addToDocument(messageId, MISMATCHED_IDENTITIES, @@ -206,6 +207,19 @@ public void migrateThreadId(long oldThreadId, long newThreadId) { contentValues.put(THREAD_ID, newThreadId); db.update(getTableName(), contentValues, where, args); } + + public boolean isOutgoing(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + try(Cursor cursor = db.query(getTableName(), new String[]{getTypeColumn()}, + ID_WHERE, new String[]{String.valueOf(messageId)}, + null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(0)); + } + } + return false; + } + public static class SyncMessageId { private final Address address; 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 244f5db117b..3a13690e8b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -162,7 +162,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val ourAddress = messageId.address val columnName = if (deliveryReceipt) DELIVERY_RECEIPT_COUNT else READ_RECEIPT_COUNT - if (ourAddress.equals(theirAddress) || theirAddress.isGroup) { + if (ourAddress.equals(theirAddress) || theirAddress.isGroupOrCommunity) { val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) val status = @@ -195,6 +195,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } + fun updateInfoMessage(messageId: Long, body: String?, runThreadUpdate: Boolean = true) { + val threadId = getThreadIdForMessage(messageId) + val db = databaseHelper.writableDatabase + db.execSQL( + "UPDATE $TABLE_NAME SET $BODY = ? WHERE $ID = ?", + arrayOf(body, messageId.toString()) + ) + with (get(context).threadDatabase()) { + setLastSeen(threadId) + setHasSent(threadId, true) + if (runThreadUpdate) { + update(threadId, true) + } + } + } + fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) { val db = databaseHelper.writableDatabase db.execSQL( @@ -329,7 +345,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(HAS_MENTION, 0) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) val attachmentDatabase = get(context).attachmentDatabase() - queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) + queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) } val threadId = getThreadIdForMessage(messageId) val deletedType = if (isOutgoing) { MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE} else { @@ -763,7 +779,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues, insertListener, ) - if (message.recipient.address.isGroup) { + if (message.recipient.address.isGroupOrCommunity) { val members = get(context).groupDatabase() .getGroupMembers(message.recipient.address.toGroupString(), false) val receiptDatabase = get(context).groupReceiptDatabase() @@ -871,23 +887,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 @@ -910,7 +909,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } val idsAsString = queryBuilder.toString() val attachmentDatabase = get(context).attachmentDatabase() - queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }) + queue { attachmentDatabase.deleteAttachmentsForMessages(messageIds) } val groupReceiptDatabase = get(context).groupReceiptDatabase() groupReceiptDatabase.deleteRowsForMessages(messageIds) val database = databaseHelper.writableDatabase @@ -920,12 +919,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa notifyStickerPackListeners() } + override fun getTypeColumn(): String = MESSAGE_BOX + // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?" // - it is "Was the thread deleted because removing that message resulted in an empty thread"! override fun deleteMessage(messageId: Long): Boolean { val threadId = getThreadIdForMessage(messageId) val attachmentDatabase = get(context).attachmentDatabase() - queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) + queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) } val groupReceiptDatabase = get(context).groupReceiptDatabase() groupReceiptDatabase.deleteRowsForMessage(messageId) val database = databaseHelper.writableDatabase @@ -941,6 +942,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val argsArray = messageIds.map { "?" } val argValues = messageIds.map { it.toString() }.toTypedArray() + val attachmentDatabase = get(context).attachmentDatabase() + val groupReceiptDatabase = get(context).groupReceiptDatabase() + + queue { attachmentDatabase.deleteAttachmentsForMessages(messageIds) } + groupReceiptDatabase.deleteRowsForMessages(messageIds) + val db = databaseHelper.writableDatabase db.delete( TABLE_NAME, @@ -976,6 +983,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) + 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) + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + } + private fun getSerializedSharedContacts( insertedAttachmentIds: Map, contacts: List @@ -1119,7 +1182,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return false } - /*package*/ private fun deleteThreads(threadIds: Set) { val db = databaseHelper.writableDatabase val where = StringBuilder() @@ -1153,7 +1215,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } /*package*/ - fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) { + fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) { var cursor: Cursor? = null try { val db = databaseHelper.readableDatabase @@ -1163,7 +1225,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa where += " WHEN $outgoingType THEN $DATE_SENT < $date" } where += " ELSE $DATE_RECEIVED < $date END)" - cursor = db!!.query( + if (onlyMedia) where += " AND $PART_COUNT >= 1" + cursor = db.query( TABLE_NAME, arrayOf(ID), where, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 510272cfb8f..3ff600eefc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -40,7 +40,9 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.io.Closeable; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; import kotlin.Pair; @@ -272,38 +274,36 @@ public long getLastMessageID(long threadId) { } } - // Builds up and returns a list of all all the messages sent by this user in the given thread. - // Used to do a pass through our local database to remove records when a user has "Ban & Delete" - // called on them in a Community. - public Set getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) { - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; - Set identifiedMessages = new HashSet(); + public List getUserMessages(long threadId, String sender) { - // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { - try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { - MessageRecord messageRecord; - while ((messageRecord = reader.getNext()) != null) { - identifiedMessages.add(messageRecord); + List idList = new ArrayList<>(); + + try (Cursor cursor = getConversation(threadId, false)) { + Reader reader = readerFor(cursor); + while (reader.getNext() != null) { + MessageRecord record = reader.getCurrent(); + if (record.getIndividualRecipient().getAddress().serialize().equals(sender)) { + idList.add(record); } } } - return identifiedMessages; + + return idList; } - // Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message - // Ids rather than the set of MessageRecords - currently unused by potentially useful in the future. - public Set getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) { + // Builds up and returns a list of all all the messages sent by this user in the given thread. + // Used to do a pass through our local database to remove records when a user has "Ban & Delete" + // called on them in a Community. + public Set getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; - - Set identifiedMessages = new HashSet(); + Set identifiedMessages = new HashSet(); // Try everything with resources so that they auto-close on end of scope try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { - identifiedMessages.add(messageRecord.id); + identifiedMessages.add(messageRecord); } } } 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 dcd7778c9a2..fb32fad978c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -65,13 +65,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, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, UNIDENTIFIED_ACCESS_MODE, - FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS + FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD, }; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -110,6 +111,17 @@ public static String getCreateNotificationTypeCommand() { "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.accountID+" "+ + "FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))"; + } + public static String getCreateApprovedCommand() { return "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;"; @@ -184,31 +196,32 @@ public Optional getRecipientSettings(@NonNull Address address } 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 disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE)); - 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(SESSION_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 disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE)); + 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(SESSION_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; @@ -232,7 +245,7 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { } return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, - notifyType, + notifyType, autoDownloadAttachments, Recipient.DisappearingState.fromId(disappearingState), Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(callVibrateState), @@ -246,6 +259,22 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { 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()); @@ -321,6 +350,21 @@ public void setBlocked(@NonNull Iterable recipients, boolean blocked) 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 27b3e73397c..70d12c0c32b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -3,10 +3,9 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor -import androidx.core.database.getStringOrNull import org.json.JSONArray import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.utilities.AccountId +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -14,7 +13,7 @@ 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 accountID = "session_id" const val name = "name" const val nickname = "nickname" @@ -83,23 +82,20 @@ 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, "$accountID = ?", arrayOf( contact.accountID )) notifyConversationListListeners() } fun contactFromCursor(cursor: Cursor): Contact { - val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID)) - val contact = Contact(accountID) - 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 { + val contact = Contact(cursor.getString(accountID)) + 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/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 6deca0c939c..a308bf30dd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -322,6 +322,11 @@ public boolean isDeletedMessage(long timestamp) { return isDeleted; } + @Override + public String getTypeColumn() { + return TYPE; + } + public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); Cursor cursor = null; @@ -723,15 +728,14 @@ private boolean isDuplicate(OutgoingTextMessage message, long threadId) { } } - void deleteMessagesInThreadBeforeDate(long threadId, long date) { + void deleteMessagesFrom(long threadId, String fromUser) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); - String where = THREAD_ID + " = ? AND (CASE " + TYPE; - - for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) { - where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date; - } + db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser}); + } - where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)"); + void deleteMessagesInThreadBeforeDate(long threadId, long date) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date; db.delete(TABLE_NAME, where, new String[] {threadId + ""}); } 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 384be73cb36..225ab4cb5de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,40 +2,34 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import com.goterl.lazysodium.utils.KeyPair +import dagger.hilt.android.qualifiers.ApplicationContext import java.security.MessageDigest -import network.loki.messenger.libsession_util.ConfigBase 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.UserGroupsConfig -import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.util.BaseCommunityInfo -import network.loki.messenger.libsession_util.util.Contact as LibSessionContact -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.UserPic -import network.loki.messenger.libsession_util.util.afterSend import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType 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.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob -import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.ExpirationConfiguration 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 @@ -55,27 +49,24 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment 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.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.AccountId 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.SnodeAPI +import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment -import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.getType -import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer @@ -85,103 +76,159 @@ 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.AccountId import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.ClosedGroupManager import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.PartAuthority -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol +import javax.inject.Inject +import javax.inject.Singleton +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact +import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember private const val TAG = "Storage" -open class Storage( - context: Context, +@Singleton +open class Storage @Inject constructor( + @ApplicationContext context: Context, helper: SQLCipherOpenHelper, - val configFactory: ConfigFactory + private val configFactory: ConfigFactory, + private val jobDatabase: SessionJobDatabase, + private val threadDatabase: ThreadDatabase, + private val recipientDatabase: RecipientDatabase, + private val attachmentDatabase: AttachmentDatabase, + private val lokiAPIDatabase: LokiAPIDatabase, + private val groupDatabase: GroupDatabase, + private val lokiMessageDatabase: LokiMessageDatabase, + private val mmsSmsDatabase: MmsSmsDatabase, + private val mmsDatabase: MmsDatabase, + private val smsDatabase: SmsDatabase, + private val blindedIdMappingDatabase: BlindedIdMappingDatabase, + private val groupMemberDatabase: GroupMemberDatabase, + private val reactionDatabase: ReactionDatabase, + private val lokiThreadDatabase: LokiThreadDatabase, + private val sessionContactDatabase: SessionContactDatabase, + private val expirationConfigurationDatabase: ExpirationConfigurationDatabase, + private val profileManager: SSKEnvironment.ProfileManagerProtocol, + private val notificationManager: MessageNotifier, + private val messageDataProvider: MessageDataProvider, + private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + private val clock: SnodeClock, + private val preferences: TextSecurePreferences, ) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { + init { + threadDatabase.setUpdateListener(this) + } + override fun threadCreated(address: Address, threadId: Long) { val localUserAddress = getUserPublicKey() ?: return if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests - val volatile = configFactory.convoVolatile ?: return - if (address.isGroup) { - val groups = configFactory.userGroups ?: return - if (address.isClosedGroup) { + when { + address.isLegacyGroup -> { val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) val closedGroup = getGroup(address.toGroupString()) if (closedGroup != null && closedGroup.isActive) { - val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId) - groups.set(legacyGroup) - val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy( - lastRead = SnodeAPI.nowWithOffset, - ) - volatile.set(newVolatileParams) + configFactory.withMutableUserConfigs { configs -> + val legacyGroup = configs.userGroups.getOrConstructLegacyGroupInfo(accountId) + configs.userGroups.set(legacyGroup) + val newVolatileParams = configs.convoInfoVolatile.getOrConstructLegacyGroup(accountId).copy( + lastRead = clock.currentTimeMills(), + ) + configs.convoInfoVolatile.set(newVolatileParams) + } + } - } else if (address.isCommunity) { + } + address.isGroupV2 -> { + configFactory.withMutableUserConfigs { configs -> + val accountId = address.serialize() + configs.userGroups.getClosedGroup(accountId) + ?: return@withMutableUserConfigs Log.d("Closed group doesn't exist locally", NullPointerException()) + + configs.convoInfoVolatile.getOrConstructClosedGroup(accountId) + } + + } + address.isCommunity -> { // 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 - if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return - // don't update our own address into the contacts DB - if (getUserPublicKey() != address.serialize()) { - val contacts = configFactory.contacts ?: return - contacts.upsertContact(address.serialize()) { - priority = PRIORITY_VISIBLE + + address.isContact -> { + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return + // don't update our own address into the contacts DB + if (getUserPublicKey() != address.serialize()) { + configFactory.withMutableUserConfigs { configs -> + configs.contacts.upsertContact(address.serialize()) { + priority = PRIORITY_VISIBLE + } + } + } else { + configFactory.withMutableUserConfigs { configs -> + configs.userProfile.setNtsPriority(PRIORITY_VISIBLE) + } + + threadDatabase.setHasSent(threadId, true) + } + + configFactory.withMutableUserConfigs { configs -> + configs.convoInfoVolatile.getOrConstructOneToOne(address.serialize()) } - } else { - val userProfile = configFactory.user ?: return - userProfile.setNtsPriority(PRIORITY_VISIBLE) - DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true) } - val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize()) - volatile.set(newVolatileParams) } } override fun threadDeleted(address: Address, threadId: Long) { - val volatile = configFactory.convoVolatile ?: return - if (address.isGroup) { - val groups = configFactory.userGroups ?: return - if (address.isClosedGroup) { - val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) - volatile.eraseLegacyClosedGroup(accountId) - groups.eraseLegacyGroup(accountId) - } else if (address.isCommunity) { - // 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 { - // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config - if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return - volatile.eraseOneToOne(address.serialize()) - if (getUserPublicKey() != address.serialize()) { - val contacts = configFactory.contacts ?: return - contacts.upsertContact(address.serialize()) { - priority = PRIORITY_HIDDEN + configFactory.withMutableUserConfigs { configs -> + if (address.isGroupOrCommunity) { + if (address.isLegacyGroup) { + val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) + configs.convoInfoVolatile.eraseLegacyClosedGroup(accountId) + configs.userGroups.eraseLegacyGroup(accountId) + } else if (address.isCommunity) { + // 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.isGroupV2) { + Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere") } } else { - val userProfile = configFactory.user ?: return - userProfile.setNtsPriority(PRIORITY_HIDDEN) + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return@withMutableUserConfigs + configs.convoInfoVolatile.eraseOneToOne(address.serialize()) + if (getUserPublicKey() != address.serialize()) { + configs.contacts.upsertContact(address.serialize()) { + priority = PRIORITY_HIDDEN + } + } else { + configs.userProfile.setNtsPriority(PRIORITY_HIDDEN) + } } + + Unit } - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } override fun getUserPublicKey(): String? { - return TextSecurePreferences.getLocalNumber(context) + return preferences.getLocalNumber() } override fun getUserX25519KeyPair(): ECKeyPair { - return DatabaseComponent.get(context).lokiAPIDatabase().getUserX25519KeyPair() + return lokiAPIDatabase.getUserX25519KeyPair() + } + + override fun getUserED25519KeyPair(): KeyPair? { + return KeyPairUtilities.getUserED25519KeyPair(context) } override fun getUserProfile(): Profile { @@ -192,13 +239,13 @@ open class Storage( } override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) { - val db = DatabaseComponent.get(context).recipientDatabase() + val db = recipientDatabase db.setProfileAvatar(recipient, newProfilePicture) db.setProfileKey(recipient, newProfileKey) } override fun setBlocksCommunityMessageRequests(recipient: Recipient, blocksMessageRequests: Boolean) { - val db = DatabaseComponent.get(context).recipientDatabase() + val db = recipientDatabase db.setBlocksCommunityMessageRequests(recipient, blocksMessageRequests) } @@ -207,8 +254,8 @@ open class Storage( Recipient.from(context, it, false) } ourRecipient.resolve().profileKey = newProfileKey - TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) }) - TextSecurePreferences.setProfilePictureURL(context, newProfilePicture) + preferences.setProfileKey(newProfileKey?.let { Base64.encodeBytes(it) }) + preferences.setProfilePictureURL(newProfilePicture) if (newProfileKey != null) { JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address, newProfileKey)) @@ -225,46 +272,83 @@ open class Storage( } override fun persistAttachments(messageID: Long, attachments: List): List { - val database = DatabaseComponent.get(context).attachmentDatabase() + val database = attachmentDatabase val databaseAttachments = attachments.mapNotNull { it.toSignalAttachment() } return database.insertAttachments(messageID, databaseAttachments) } override fun getAttachmentsForMessage(messageID: Long): List { - val database = DatabaseComponent.get(context).attachmentDatabase() - return database.getAttachmentsForMessage(messageID) + return attachmentDatabase.getAttachmentsForMessage(messageID) } override fun getLastSeen(threadId: Long): Long { - val threadDb = DatabaseComponent.get(context).threadDatabase() - return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L + return threadDatabase.getLastSeenAndHasSent(threadId)?.first() ?: 0L + } + + override fun ensureMessageHashesAreSender( + hashes: Set, + sender: String, + closedGroupId: String + ): Boolean { + val threadId = getThreadId(fromSerialized(closedGroupId))!! + val senderIsMe = sender == getUserPublicKey() + + val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes) + + if (senderIsMe) { + return info.all { it.isOutgoing } + } else { + return info.all { it.sender == sender } + } + } + + override fun deleteMessagesByHash(threadId: Long, hashes: List) { + for (info in lokiMessageDatabase.getSendersForHashes(threadId, hashes.toSet())) { + messageDataProvider.deleteMessage(info.messageId, info.isSms) + if (!info.isOutgoing) { + notificationManager.updateNotification(context) + } + } + } + + override fun deleteMessagesByUser(threadId: Long, userSessionId: String) { + val userMessages = mmsSmsDatabase.getUserMessages(threadId, userSessionId) + val (mmsMessages, smsMessages) = userMessages.partition { it.isMms } + if (mmsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(mmsMessages.map(MessageRecord::id), threadId, isSms = false) + } + if (smsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(smsMessages.map(MessageRecord::id), threadId, isSms = true) + } } override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { - val threadDb = DatabaseComponent.get(context).threadDatabase() + val threadDb = threadDatabase getRecipientForThread(threadId)?.let { recipient -> val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() // don't set the last read in the volatile if we didn't set it in the DB - if (!threadDb.markAllAsRead(threadId, recipient.isGroupRecipient, lastSeenTime, force) && !force) return + if (!threadDb.markAllAsRead(threadId, recipient.isGroupOrCommunityRecipient, lastSeenTime, force) && !force) return // don't process configs for inbox recipients - if (recipient.isOpenGroupInboxRecipient) return + if (recipient.isCommunityInboxRecipient) return - configFactory.convoVolatile?.let { config -> + configFactory.withMutableUserConfigs { configs -> + val config = configs.convoInfoVolatile val convo = when { // recipient closed group - recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + recipient.isLegacyGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + recipient.isGroupV2Recipient -> config.getOrConstructClosedGroup(recipient.address.serialize()) // recipient is open group recipient.isCommunityRecipient -> { - val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return + val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return@withMutableUserConfigs BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> config.getOrConstructCommunity(base, room, pubKey) - } ?: return + } ?: return@withMutableUserConfigs } // otherwise recipient is one to one recipient.isContactRecipient -> { // don't process non-standard account IDs though - if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return + if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return@withMutableUserConfigs config.getOrConstructOneToOne(recipient.address.serialize()) } else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}") @@ -275,13 +359,12 @@ open class Storage( notifyConversationListListeners() } config.set(convo) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } } } override fun updateThread(threadId: Long, unarchive: Boolean) { - val threadDb = DatabaseComponent.get(context).threadDatabase() + val threadDb = threadDatabase threadDb.update(threadId, unarchive) } @@ -299,6 +382,9 @@ open class Storage( ?.let { SodiumUtilities.accountId(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)) @@ -311,12 +397,19 @@ open class Storage( 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 } val targetRecipient = Recipient.from(context, targetAddress, false) - if (!targetRecipient.isGroupRecipient) { + if (!targetRecipient.isGroupOrCommunityRecipient) { if (isUserSender || isUserBlindedSender) { setRecipientApproved(targetRecipient, true) } else { @@ -333,7 +426,6 @@ open class Storage( if (message.isMediaMessage() || attachments.isNotEmpty()) { val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val insertResult = if (isUserSender || isUserBlindedSender) { val mediaMessage = OutgoingMediaMessage.from( message, @@ -357,7 +449,6 @@ open class Storage( messageID = insertResult.get().messageId } } else { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() val isOpenGroupInvitation = (message.openGroupInvitation != null) val insertResult = if (isUserSender || isUserBlindedSender) { @@ -376,71 +467,61 @@ open class Storage( } message.serverHash?.let { serverHash -> messageID?.let { id -> - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, message.isMediaMessage(), serverHash) + lokiMessageDatabase.setMessageServerHash(id, message.isMediaMessage(), serverHash) } } return messageID } override fun persistJob(job: Job) { - DatabaseComponent.get(context).sessionJobDatabase().persistJob(job) + jobDatabase.persistJob(job) } override fun markJobAsSucceeded(jobId: String) { - DatabaseComponent.get(context).sessionJobDatabase().markJobAsSucceeded(jobId) + jobDatabase.markJobAsSucceeded(jobId) } override fun markJobAsFailedPermanently(jobId: String) { - DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId) + jobDatabase.markJobAsFailedPermanently(jobId) } override fun getAllPendingJobs(vararg types: String): Map { - return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types) + return jobDatabase.getAllJobs(*types) } override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { - return DatabaseComponent.get(context).sessionJobDatabase().getAttachmentUploadJob(attachmentID) + return jobDatabase.getAttachmentUploadJob(attachmentID) } override fun getMessageSendJob(messageSendJobID: String): MessageSendJob? { - return DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) + return jobDatabase.getMessageSendJob(messageSendJobID) } override fun getMessageReceiveJob(messageReceiveJobID: String): MessageReceiveJob? { - return DatabaseComponent.get(context).sessionJobDatabase().getMessageReceiveJob(messageReceiveJobID) + return jobDatabase.getMessageReceiveJob(messageReceiveJobID) } override fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? { - return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId) - } - - override fun getConfigSyncJob(destination: Destination): Job? { - return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(ConfigurationSyncJob.KEY).values.firstOrNull { - (it as? ConfigurationSyncJob)?.destination == destination - } + return jobDatabase.getGroupAvatarDownloadJob(server, room, imageId) } override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) { - val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return + val job = jobDatabase.getMessageSendJob(messageSendJobID) ?: return JobQueue.shared.resumePendingSendMessage(job) } override fun isJobCanceled(job: Job): Boolean { - return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job) + return jobDatabase.isJobCanceled(job) } override fun cancelPendingMessageSendJobs(threadID: Long) { - val jobDb = DatabaseComponent.get(context).sessionJobDatabase() + val jobDb = jobDatabase jobDb.cancelPendingMessageSendJobs(threadID) } override fun getAuthToken(room: String, server: String): String? { val id = "$server.$room" - return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) - } - - override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { - notifyUpdates(forConfigObject, messageTimestamp) + return lokiAPIDatabase.getAuthToken(id) } override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean { @@ -452,210 +533,36 @@ open class Storage( } override fun isCheckingCommunityRequests(): Boolean { - return configFactory.user?.getCommunityMessageRequests() == true - } - - private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { - when (forConfigObject) { - is UserProfile -> updateUser(forConfigObject, messageTimestamp) - is Contacts -> updateContacts(forConfigObject, messageTimestamp) - is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp) - is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp) - } - } - - private fun updateUser(userProfile: UserProfile, messageTimestamp: Long) { - val userPublicKey = getUserPublicKey() ?: return - // would love to get rid of recipient and context from this - val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) - - // Update profile name - val name = userProfile.getName() ?: return - val userPic = userProfile.getPic() - val profileManager = SSKEnvironment.shared.profileManager - - name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let { - TextSecurePreferences.setProfileName(context, it) - profileManager.setName(context, recipient, it) - if (it != name) userProfile.setName(it) - } - - // Update profile picture - if (userPic == UserPic.DEFAULT) { - clearUserPic() - } else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty() - && TextSecurePreferences.getProfilePictureURL(context) != userPic.url) { - setUserProfilePicture(userPic.url, userPic.key) - } - - if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) { - // delete nts thread if needed - val ourThread = getThreadId(recipient) ?: return - deleteConversation(ourThread) - } else { - // create note to self thread if needed (?) - val address = recipient.address - val ourThread = getThreadId(address) ?: getOrCreateThreadIdFor(address).also { - setThreadDate(it, 0) - } - DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true) - setPinned(ourThread, userProfile.getNtsPriority() > 0) - } - - // Set or reset the shared library to use latest expiration config - getThreadId(recipient)?.let { - setExpirationConfiguration( - getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp) - ) - } - } - - private fun updateContacts(contacts: Contacts, messageTimestamp: Long) { - val extracted = contacts.all().toList() - addLibSessionContacts(extracted, messageTimestamp) + return configFactory.withUserConfigs { it.userProfile.getCommunityMessageRequests() } } - override fun clearUserPic() { + override fun clearUserPic(clearConfig: Boolean) { val userPublicKey = getUserPublicKey() ?: return Log.w(TAG, "No user public key when trying to clear user pic") - val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() - val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) // Clear details related to the user's profile picture - TextSecurePreferences.setProfileKey(context, null) + preferences.setProfileKey(null) ProfileKeyUtil.setEncodedProfileKey(context, null) recipientDatabase.setProfileAvatar(recipient, null) - TextSecurePreferences.setProfileAvatarId(context, 0) - TextSecurePreferences.setProfilePictureURL(context, null) + preferences.setProfileAvatarId(0) + preferences.setProfilePictureURL(null) Recipient.removeCached(fromSerialized(userPublicKey)) - configFactory.user?.setPic(UserPic.DEFAULT) - } - - private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) { - val extracted = convos.all().filterNotNull() - for (conversation in extracted) { - val threadId = when (conversation) { - is Conversation.OneToOne -> getThreadIdFor(conversation.accountId, 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) - } - if (threadId != null) { - if (conversation.lastRead > getLastSeen(threadId)) { - markConversationAsRead(threadId, conversation.lastRead, force = true) - } - updateThread(threadId, false) - } - } - } - - private fun updateUserGroups(userGroups: UserGroupsConfig, messageTimestamp: Long) { - val threadDb = DatabaseComponent.get(context).threadDatabase() - val localUserPublicKey = getUserPublicKey() ?: return Log.w( - "Loki", - "No user public key when trying to update user groups from config" - ) - val communities = userGroups.allCommunityInfo() - val lgc = userGroups.allLegacyGroupInfo() - val allOpenGroups = getAllOpenGroups() - val toDeleteCommunities = allOpenGroups.filter { - Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() } - } - - val existingCommunities: Map = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys } - 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.accountId } - val toDeleteClosedGroups = existingClosedGroups.filter { group -> - GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds - } - - // delete the ones which are not listed in the config - toDeleteCommunities.values.forEach { openGroup -> - OpenGroupManager.delete(openGroup.server, openGroup.room, context) - } - - toDeleteClosedGroups.forEach { deleteGroup -> - val threadId = getThreadId(deleteGroup.encodedId) - if (threadId != null) { - ClosedGroupManager.silentlyRemoveGroup(context,threadId,GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true) - } - } - - toAddCommunities.forEach { toAddCommunity -> - val joinUrl = toAddCommunity.community.fullUrl() - if (!hasBackgroundGroupAddJob(joinUrl)) { - JobQueue.shared.add(BackgroundGroupAddJob(joinUrl)) - } - } - - for (groupInfo in communities) { - val groupBaseCommunity = groupInfo.community - if (groupBaseCommunity.fullUrl() in existingJoinUrls) { - // add it - val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() } - threadDb.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED) - } - } - - for (group in lgc) { - val groupId = GroupUtil.doubleEncodeGroupID(group.accountId) - val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId } - val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } - if (existingGroup != null) { - if (group.priority == PRIORITY_HIDDEN && existingThread != null) { - ClosedGroupManager.silentlyRemoveGroup(context,existingThread,GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true) - } else if (existingThread == null) { - Log.w("Loki-DBG", "Existing group had no thread to hide") - } else { - Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}") - threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED) - } - } 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 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.accountId) - // Store the encryption key pair - val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) - addClosedGroupEncryptionKeyPair(keyPair, group.accountId, SnodeAPI.nowWithOffset) - // Notify the PN server - PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey) - // Notify the user - val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) - threadDb.setDate(threadID, formationTimestamp) - - // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group, - // which in turn allows us to show the `groupNoMessages` control message text. - //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.accountId) - } - getThreadId(Address.fromSerialized(groupId))?.let { - setExpirationConfiguration( - getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } - ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) - ) + if (clearConfig) { + configFactory.withMutableUserConfigs { + it.userProfile.setPic(UserPic.DEFAULT) } } } override fun setAuthToken(room: String, server: String, newValue: String) { val id = "$server.$room" - DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue) + lokiAPIDatabase.setAuthToken(id, newValue) } override fun removeAuthToken(room: String, server: String) { val id = "$server.$room" - DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, null) + lokiAPIDatabase.setAuthToken(id, null) } override fun getOpenGroup(threadId: Long): OpenGroup? { @@ -668,44 +575,44 @@ open class Storage( } override fun getOpenGroupPublicKey(server: String): String? { - return DatabaseComponent.get(context).lokiAPIDatabase().getOpenGroupPublicKey(server) + return lokiAPIDatabase.getOpenGroupPublicKey(server) } override fun setOpenGroupPublicKey(server: String, newValue: String) { - DatabaseComponent.get(context).lokiAPIDatabase().setOpenGroupPublicKey(server, newValue) + lokiAPIDatabase.setOpenGroupPublicKey(server, newValue) } override fun getLastMessageServerID(room: String, server: String): Long? { - return DatabaseComponent.get(context).lokiAPIDatabase().getLastMessageServerID(room, server) + return lokiAPIDatabase.getLastMessageServerID(room, server) } override fun setLastMessageServerID(room: String, server: String, newValue: Long) { - DatabaseComponent.get(context).lokiAPIDatabase().setLastMessageServerID(room, server, newValue) + lokiAPIDatabase.setLastMessageServerID(room, server, newValue) } override fun removeLastMessageServerID(room: String, server: String) { - DatabaseComponent.get(context).lokiAPIDatabase().removeLastMessageServerID(room, server) + lokiAPIDatabase.removeLastMessageServerID(room, server) } override fun getLastDeletionServerID(room: String, server: String): Long? { - return DatabaseComponent.get(context).lokiAPIDatabase().getLastDeletionServerID(room, server) + return lokiAPIDatabase.getLastDeletionServerID(room, server) } override fun setLastDeletionServerID(room: String, server: String, newValue: Long) { - DatabaseComponent.get(context).lokiAPIDatabase().setLastDeletionServerID(room, server, newValue) + lokiAPIDatabase.setLastDeletionServerID(room, server, newValue) } override fun removeLastDeletionServerID(room: String, server: String) { - DatabaseComponent.get(context).lokiAPIDatabase().removeLastDeletionServerID(room, server) + lokiAPIDatabase.removeLastDeletionServerID(room, server) } override fun setUserCount(room: String, server: String, newValue: Int) { - DatabaseComponent.get(context).lokiAPIDatabase().setUserCount(room, server, newValue) + lokiAPIDatabase.setUserCount(room, server, newValue) } override fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) { - DatabaseComponent.get(context).lokiMessageDatabase().setServerID(messageID, serverID, isSms) - DatabaseComponent.get(context).lokiMessageDatabase().setOriginalThreadID(messageID, serverID, threadID) + lokiMessageDatabase.setServerID(messageID, serverID, isSms) + lokiMessageDatabase.setOriginalThreadID(messageID, serverID, threadID) } override fun getOpenGroup(room: String, server: String): OpenGroup? { @@ -713,7 +620,7 @@ open class Storage( } override fun setGroupMemberRoles(members: List) { - DatabaseComponent.get(context).groupMemberDatabase().setGroupMembers(members) + groupMemberDatabase.setGroupMembers(members) } override fun isDuplicateMessage(timestamp: Long): Boolean { @@ -721,19 +628,19 @@ open class Storage( } override fun updateTitle(groupID: String, newValue: String) { - DatabaseComponent.get(context).groupDatabase().updateTitle(groupID, newValue) + groupDatabase.updateTitle(groupID, newValue) } override fun updateProfilePicture(groupID: String, newValue: ByteArray) { - DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue) + groupDatabase.updateProfilePicture(groupID, newValue) } override fun removeProfilePicture(groupID: String) { - DatabaseComponent.get(context).groupDatabase().removeProfilePicture(groupID) + groupDatabase.removeProfilePicture(groupID) } override fun hasDownloadedProfilePicture(groupID: String): Boolean { - return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID) + return groupDatabase.hasDownloadedProfilePicture(groupID) } override fun getReceivedMessageTimestamps(): Set { @@ -749,15 +656,14 @@ open class Storage( } override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? { - val database = DatabaseComponent.get(context).mmsSmsDatabase() + val database = mmsSmsDatabase val address = fromSerialized(author) return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } override fun getMessageType(timestamp: Long, author: String): MessageType? { - val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = fromSerialized(author) - return database.getMessageFor(timestamp, address)?.individualRecipient?.getType() + return mmsSmsDatabase.getMessageFor(timestamp, address)?.individualRecipient?.getType() } override fun updateSentTimestamp( @@ -767,16 +673,16 @@ open class Storage( threadId: Long ) { if (isMms) { - val mmsDb = DatabaseComponent.get(context).mmsDatabase() + val mmsDb = mmsDatabase mmsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId) } else { - val smsDb = DatabaseComponent.get(context).smsDatabase() + val smsDb = smsDatabase smsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId) } } override fun markAsSent(timestamp: Long, author: String) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() + val database = mmsSmsDatabase val messageRecord = database.getSentMessageFor(timestamp, author) if (messageRecord == null) { Log.w(TAG, "Failed to retrieve local message record in Storage.markAsSent - aborting.") @@ -784,17 +690,17 @@ open class Storage( } if (messageRecord.isMms) { - DatabaseComponent.get(context).mmsDatabase().markAsSent(messageRecord.getId(), true) + mmsDatabase.markAsSent(messageRecord.getId(), true) } else { - DatabaseComponent.get(context).smsDatabase().markAsSent(messageRecord.getId(), true) + smsDatabase.markAsSent(messageRecord.getId(), true) } } // Method that marks a message as sent in Communities (only!) - where the server modifies the // message timestamp and as such we cannot use that to identify the local message. override fun markAsSentToCommunity(threadId: Long, messageID: Long) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() - val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context)) + val database = mmsSmsDatabase + val message = database.getLastSentMessageRecordFromSender(threadId, preferences.getLocalNumber()) // Ensure we can find the local message.. if (message == null) { @@ -804,53 +710,53 @@ open class Storage( // ..and mark as sent if found. if (message.isMms) { - DatabaseComponent.get(context).mmsDatabase().markAsSent(message.getId(), true) + mmsDatabase.markAsSent(message.getId(), true) } else { - DatabaseComponent.get(context).smsDatabase().markAsSent(message.getId(), true) + smsDatabase.markAsSent(message.getId(), true) } } override fun markAsSyncing(timestamp: Long, author: String) { - DatabaseComponent.get(context).mmsSmsDatabase() + mmsSmsDatabase .getMessageFor(timestamp, author) ?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) } } private fun getMmsDatabaseElseSms(isMms: Boolean) = - if (isMms) DatabaseComponent.get(context).mmsDatabase() - else DatabaseComponent.get(context).smsDatabase() + if (isMms) mmsDatabase + else smsDatabase override fun markAsResyncing(timestamp: Long, author: String) { - DatabaseComponent.get(context).mmsSmsDatabase() + mmsSmsDatabase .getMessageFor(timestamp, author) ?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) } } override fun markAsSending(timestamp: Long, author: String) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() + val database = mmsSmsDatabase val messageRecord = database.getMessageFor(timestamp, author) ?: return if (messageRecord.isMms) { - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() + val mmsDatabase = mmsDatabase mmsDatabase.markAsSending(messageRecord.getId()) } else { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() + val smsDatabase = smsDatabase smsDatabase.markAsSending(messageRecord.getId()) messageRecord.isPending } } override fun markUnidentified(timestamp: Long, author: String) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() + val database = mmsSmsDatabase val messageRecord = database.getMessageFor(timestamp, author) if (messageRecord == null) { Log.w(TAG, "Could not identify message with timestamp: $timestamp from author: $author") return } if (messageRecord.isMms) { - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() + val mmsDatabase = mmsDatabase mmsDatabase.markUnidentified(messageRecord.getId(), true) } else { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() + val smsDatabase = smsDatabase smsDatabase.markUnidentified(messageRecord.getId(), true) } } @@ -858,8 +764,8 @@ open class Storage( // Method that marks a message as unidentified in Communities (only!) - where the server // modifies the message timestamp and as such we cannot use that to identify the local message. override fun markUnidentifiedInCommunity(threadId: Long, messageId: Long) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() - val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context)) + val database = mmsSmsDatabase + val message = database.getLastSentMessageRecordFromSender(threadId, preferences.getLocalNumber()) // Check to ensure the message exists if (message == null) { @@ -869,20 +775,20 @@ open class Storage( // Mark it as unidentified if we found the message successfully if (message.isMms) { - DatabaseComponent.get(context).mmsDatabase().markUnidentified(message.getId(), true) + mmsDatabase.markUnidentified(message.getId(), true) } else { - DatabaseComponent.get(context).smsDatabase().markUnidentified(message.getId(), true) + smsDatabase.markUnidentified(message.getId(), true) } } override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() + val database = mmsSmsDatabase val messageRecord = database.getMessageFor(timestamp, author) ?: return if (messageRecord.isMms) { - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() + val mmsDatabase = mmsDatabase mmsDatabase.markAsSentFailed(messageRecord.getId()) } else { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() + val smsDatabase = smsDatabase smsDatabase.markAsSentFailed(messageRecord.getId()) } if (error.localizedMessage != null) { @@ -892,14 +798,14 @@ open class Storage( } else { message = error.localizedMessage!! } - DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message) + lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message) } else { - DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) } } override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() + val database = mmsSmsDatabase val messageRecord = database.getMessageFor(timestamp, author) ?: return database.getMessageFor(timestamp, author) @@ -912,50 +818,54 @@ open class Storage( } else { message = error.localizedMessage!! } - DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message) + lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message) } else { - DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) } } override fun clearErrorMessage(messageID: Long) { - val db = DatabaseComponent.get(context).lokiMessageDatabase() + val db = lokiMessageDatabase db.clearErrorMessage(messageID) } override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash) + lokiMessageDatabase.setMessageServerHash(messageID, mms, serverHash) } override fun getGroup(groupID: String): GroupRecord? { - val group = DatabaseComponent.get(context).groupDatabase().getGroup(groupID) + val group = groupDatabase.getGroup(groupID) return if (group.isPresent) { group.get() } else null } override fun createGroup(groupId: String, title: String?, members: List
, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List
, formationTimestamp: Long) { - DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) + groupDatabase.create(groupId, title, members, avatar, relay, admins, formationTimestamp) } override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) { - val volatiles = configFactory.convoVolatile ?: return - val userGroups = configFactory.userGroups ?: return - if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return - val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) - groupVolatileConfig.lastRead = formationTimestamp - volatiles.set(groupVolatileConfig) - val groupInfo = GroupInfo.LegacyGroupInfo( - accountId = groupPublicKey, - name = name, - members = members, - priority = PRIORITY_VISIBLE, - encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte - encSecKey = encryptionKeyPair.privateKey.serialize(), - disappearingTimer = expirationTimer.toLong(), - joinedAt = (formationTimestamp / 1000L) - ) - // shouldn't exist, don't use getOrConstruct + copy - userGroups.set(groupInfo) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + configFactory.withMutableUserConfigs { + val volatiles = it.convoInfoVolatile + val userGroups = it.userGroups + if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) { + return@withMutableUserConfigs + } + + val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) + groupVolatileConfig.lastRead = formationTimestamp + volatiles.set(groupVolatileConfig) + val groupInfo = GroupInfo.LegacyGroupInfo( + accountId = groupPublicKey, + name = name, + members = members, + priority = PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = expirationTimer.toLong(), + joinedAt = (formationTimestamp / 1000L) + ) + // shouldn't exist, don't use getOrConstruct + copy + userGroups.set(groupInfo) + } } override fun updateGroupConfig(groupPublicKey: String) { @@ -963,139 +873,249 @@ open class Storage( val groupAddress = fromSerialized(groupID) val existingGroup = getGroup(groupID) ?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config") - val userGroups = configFactory.userGroups ?: return - if (!existingGroup.isActive) { - userGroups.eraseLegacyGroup(groupPublicKey) - return + configFactory.withMutableUserConfigs { + val userGroups = it.userGroups + if (!existingGroup.isActive) { + userGroups.eraseLegacyGroup(groupPublicKey) + return@withMutableUserConfigs + } + val name = existingGroup.title + val admins = existingGroup.admins.map { it.serialize() } + val members = existingGroup.members.map { it.serialize() } + val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members) + val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) + ?: return@withMutableUserConfigs Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") + + val threadID = getThreadId(groupAddress) ?: return@withMutableUserConfigs + val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy( + name = name, + members = membersMap, + encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = latestKeyPair.privateKey.serialize(), + priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE, + disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, + joinedAt = (existingGroup.formationTimestamp / 1000L) + ) + userGroups.set(groupInfo) } - val name = existingGroup.title - val admins = existingGroup.admins.map { it.serialize() } - val members = existingGroup.members.map { it.serialize() } - val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members) - val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) - ?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") - - val threadID = getThreadId(groupAddress) ?: return - val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy( - name = name, - members = membersMap, - encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte - encSecKey = latestKeyPair.privateKey.serialize(), - priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE, - disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, - joinedAt = (existingGroup.formationTimestamp / 1000L) - ) - userGroups.set(groupInfo) } override fun isGroupActive(groupPublicKey: String): Boolean { - return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true + return groupDatabase.getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true } override fun setActive(groupID: String, value: Boolean) { - DatabaseComponent.get(context).groupDatabase().setActive(groupID, value) + groupDatabase.setActive(groupID, value) } override fun getZombieMembers(groupID: String): Set { - return DatabaseComponent.get(context).groupDatabase().getGroupZombieMembers(groupID).map { it.address.serialize() }.toHashSet() + return groupDatabase.getGroupZombieMembers(groupID).map { it.address.serialize() }.toHashSet() } override fun removeMember(groupID: String, member: Address) { - DatabaseComponent.get(context).groupDatabase().removeMember(groupID, member) + groupDatabase.removeMember(groupID, member) } override fun updateMembers(groupID: String, members: List
) { - DatabaseComponent.get(context).groupDatabase().updateMembers(groupID, members) + groupDatabase.updateMembers(groupID, members) } override fun setZombieMembers(groupID: String, members: List
) { - DatabaseComponent.get(context).groupDatabase().updateZombieMembers(groupID, members) + groupDatabase.updateZombieMembers(groupID, members) } - override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) { + override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long): Long? { 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, 0, true, false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() - val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) - val smsDB = DatabaseComponent.get(context).smsDatabase() - smsDB.insertMessageInbox(infoMessage, true) + val infoMessage = IncomingGroupMessage(m, updateData, true) + val smsDB = smsDatabase + return smsDB.insertMessageInbox(infoMessage, true).orNull().messageId + } + + override fun updateInfoMessage(context: Context, messageId: Long, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection) { + val mmsDB = mmsDatabase + val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() + mmsDB.updateInfoMessage(messageId, updateData) } - override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) { + override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long): Long? { val userPublicKey = getUserPublicKey()!! val recipient = Recipient.from(context, fromSerialized(groupID), false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf()) - val mmsDB = DatabaseComponent.get(context).mmsDatabase() - val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() + val mmsDB = mmsDatabase + val mmsSmsDB = mmsSmsDatabase if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) { Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") - return + return null } val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) mmsDB.markAsSent(infoMessageID, true) + return infoMessageID } - 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 lokiAPIDatabase.isClosedGroup(publicKey) } override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList { - return DatabaseComponent.get(context).lokiAPIDatabase().getClosedGroupEncryptionKeyPairs(groupPublicKey).toMutableList() + return lokiAPIDatabase.getClosedGroupEncryptionKeyPairs(groupPublicKey).toMutableList() } override fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? { - return DatabaseComponent.get(context).lokiAPIDatabase().getLatestClosedGroupEncryptionKeyPair(groupPublicKey) + return lokiAPIDatabase.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) } override fun getAllClosedGroupPublicKeys(): Set { - return DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() + return lokiAPIDatabase.getAllClosedGroupPublicKeys() } override fun getAllActiveClosedGroupPublicKeys(): Set { - return DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys().filter { + return lokiAPIDatabase.getAllClosedGroupPublicKeys().filter { getGroup(GroupUtil.doubleEncodeGroupID(it))?.isActive == true }.toSet() } override fun addClosedGroupPublicKey(groupPublicKey: String) { - DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupPublicKey(groupPublicKey) + lokiAPIDatabase.addClosedGroupPublicKey(groupPublicKey) } override fun removeClosedGroupPublicKey(groupPublicKey: String) { - DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey) + lokiAPIDatabase.removeClosedGroupPublicKey(groupPublicKey) } override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { - DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp) + lokiAPIDatabase.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp) } override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { - DatabaseComponent.get(context).lokiAPIDatabase().removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + lokiAPIDatabase.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + } + + override fun removeClosedGroupThread(threadID: Long) { + threadDatabase.deleteConversation(threadID) } override fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) { - DatabaseComponent.get(context).groupDatabase() + groupDatabase .updateFormationTimestamp(groupID, formationTimestamp) } override fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) { - DatabaseComponent.get(context).groupDatabase() + groupDatabase .updateTimestampUpdated(groupID, updatedTimestamp) } + /** + * For new closed groups + */ + override fun getMembers(groupPublicKey: String): List = + configFactory.withGroupConfigs(AccountId(groupPublicKey)) { + it.groupMembers.all() + } + + override fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo? { + val groupIsAdmin = configFactory.getGroup(AccountId(groupAccountId))?.hasAdminKey() ?: return null + + return configFactory.withGroupConfigs(AccountId(groupAccountId)) { configs -> + val info = configs.groupInfo + GroupDisplayInfo( + id = info.id(), + name = info.getName(), + profilePic = info.getProfilePic(), + expiryTimer = info.getExpiryTimer(), + destroyed = false, + created = info.getCreated(), + description = info.getDescription(), + isUserAdmin = groupIsAdmin + ) + } + } + + override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? { + val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMills() + val senderPublicKey = message.sender + val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } + ?: configFactory.getGroup(closedGroup)?.name + + val updateData = UpdateMessageData.buildGroupUpdate(message, groupName.orEmpty()) ?: return null + + return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + } + + override fun insertGroupInfoLeaving(closedGroup: AccountId): Long? { + val sentTimestamp = clock.currentTimeMills() + val senderPublicKey = getUserPublicKey() ?: return null + val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupLeaving) + + return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + } + + override fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) { + val mmsDB = mmsDatabase + val newMessage = UpdateMessageData.buildGroupLeaveUpdate(newType) + mmsDB.updateInfoMessage(messageId, newMessage.toJSON()) + } + + override fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long? { + val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation(closedGroup.hexString, senderPublicKey, groupName)) + return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + } + + private fun insertUpdateControlMessage(updateData: UpdateMessageData, sentTimestamp: Long, senderPublicKey: String?, closedGroup: AccountId): Long? { + val userPublicKey = getUserPublicKey()!! + val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString), false) + val threadDb = threadDatabase + val threadID = threadDb.getThreadIdIfExistsFor(recipient) + val expirationConfig = getExpirationConfiguration(threadID) + val expiryMode = expirationConfig?.expiryMode + val expiresInMillis = expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 + val inviteJson = updateData.toJSON() + + + if (senderPublicKey == null || senderPublicKey == userPublicKey) { + val infoMessage = OutgoingGroupMediaMessage( + recipient, + inviteJson, + closedGroup.hexString, + null, + sentTimestamp, + expiresInMillis, + expireStartedAt, + true, + null, + listOf(), + listOf() + ) + val mmsDB = mmsDatabase + val mmsSmsDB = mmsSmsDatabase + // check for conflict here, not returning duplicate in case it's different + if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null + val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + mmsDB.markAsSent(infoMessageID, true) + return infoMessageID + } else { + val group = SignalServiceGroup(Hex.fromStringCondensed(closedGroup.hexString), SignalServiceGroup.GroupType.SIGNAL) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), expiresInMillis, expireStartedAt, true, false) + val infoMessage = IncomingGroupMessage(m, inviteJson, true) + val smsDB = smsDatabase + val insertResult = smsDB.insertMessageInbox(infoMessage, true) + return insertResult.orNull()?.messageId + } + } + override fun setServerCapabilities(server: String, capabilities: List) { - return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) + return lokiAPIDatabase.setServerCapabilities(server, capabilities) } override fun getServerCapabilities(server: String): List { - return DatabaseComponent.get(context).lokiAPIDatabase().getServerCapabilities(server) + return lokiAPIDatabase.getServerCapabilities(server) } override fun getAllOpenGroups(): Map { - return DatabaseComponent.get(context).lokiThreadDatabase().getAllOpenGroups() + return lokiThreadDatabase.getAllOpenGroups() } override fun updateOpenGroup(openGroup: OpenGroup) { @@ -1103,7 +1123,7 @@ open class Storage( } override fun getAllGroups(includeInactive: Boolean): List { - return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive) + return groupDatabase.getAllGroups(includeInactive) } override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { @@ -1112,45 +1132,50 @@ open class Storage( override fun onOpenGroupAdded(server: String, room: String) { OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) - val groups = configFactory.userGroups ?: return - val volatileConfig = configFactory.convoVolatile ?: return - val openGroup = getOpenGroup(room, server) ?: return - val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return - val pubKeyHex = Hex.toStringCondensed(pubKey) - val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex) - groups.set(communityInfo) - val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey) - if (volatile.lastRead != 0L) { - val threadId = getThreadId(openGroup) ?: return - markConversationAsRead(threadId, volatile.lastRead, force = true) + configFactory.withMutableUserConfigs { configs -> + val groups = configs.userGroups + val volatileConfig = configs.convoInfoVolatile + val openGroup = getOpenGroup(room, server) ?: return@withMutableUserConfigs + val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@withMutableUserConfigs + val pubKeyHex = Hex.toStringCondensed(pubKey) + val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex) + groups.set(communityInfo) + val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey) + if (volatile.lastRead != 0L) { + val threadId = getThreadId(openGroup) ?: return@withMutableUserConfigs + markConversationAsRead(threadId, volatile.lastRead, force = true) + } + volatileConfig.set(volatile) } - volatileConfig.set(volatile) } override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { - val jobDb = DatabaseComponent.get(context).sessionJobDatabase() - return jobDb.hasBackgroundGroupAddJob(groupJoinUrl) + return jobDatabase.hasBackgroundGroupAddJob(groupJoinUrl) } override fun setProfileSharing(address: Address, value: Boolean) { val recipient = Recipient.from(context, address, false) - DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, value) + recipientDatabase.setProfileSharing(recipient, value) } override fun getOrCreateThreadIdFor(address: Address): Long { val recipient = Recipient.from(context, address, false) - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + return threadDatabase.getOrCreateThreadIdFor(recipient) } override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? { - val database = DatabaseComponent.get(context).threadDatabase() + val database = threadDatabase 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) @@ -1173,12 +1198,12 @@ open class Storage( } override fun getThreadId(recipient: Recipient): Long? { - val threadID = DatabaseComponent.get(context).threadDatabase().getThreadIdIfExistsFor(recipient) + val threadID = threadDatabase.getThreadIdIfExistsFor(recipient) return if (threadID < 0) null else threadID } override fun getThreadIdForMms(mmsId: Long): Long { - val mmsDb = DatabaseComponent.get(context).mmsDatabase() + val mmsDb = mmsDatabase val cursor = mmsDb.getMessage(mmsId) val reader = mmsDb.readerFor(cursor) val threadId = reader.next?.threadId @@ -1187,37 +1212,40 @@ open class Storage( } override fun getContactWithAccountID(accountID: String): Contact? { - return DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(accountID) + return sessionContactDatabase.getContactWithAccountID(accountID) } override fun getAllContacts(): Set { - return DatabaseComponent.get(context).sessionContactDatabase().getAllContacts() + return sessionContactDatabase.getAllContacts() } override fun setContact(contact: Contact) { - DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) + sessionContactDatabase.setContact(contact) val address = fromSerialized(contact.accountID) if (!getRecipientApproved(address)) return - val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact) + val recipientHash = profileManager.contactUpdatedInternal(contact) val recipient = Recipient.from(context, address, false) setRecipientHash(recipient, recipientHash) } override fun getRecipientForThread(threadId: Long): Recipient? { - return DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadId) + return threadDatabase.getRecipientForThreadId(threadId) } override fun getRecipientSettings(address: Address): Recipient.RecipientSettings? { - return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull() + return recipientDatabase.getRecipientSettings(address).orNull() + } + + override fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean { + return recipientDatabase.isAutoDownloadFlagSet(recipient) } override fun addLibSessionContacts(contacts: List, timestamp: Long) { - val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + val mappingDb = blindedIdMappingDatabase val moreContacts = contacts.filter { contact -> val id = AccountId(contact.id) id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null } } - val profileManager = SSKEnvironment.shared.profileManager moreContacts.forEach { contact -> val address = fromSerialized(contact.id) val recipient = Recipient.from(context, address, false) @@ -1273,9 +1301,9 @@ open class Storage( } override fun addContacts(contacts: List) { - val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() - val threadDatabase = DatabaseComponent.get(context).threadDatabase() - val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + val recipientDatabase = recipientDatabase + val threadDatabase = threadDatabase + val mappingDb = blindedIdMappingDatabase val moreContacts = contacts.filter { contact -> val id = AccountId(contact.publicKey) id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null } @@ -1314,83 +1342,107 @@ open class Storage( } } + override fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean { + return recipient.autoDownloadAttachments + } + + override fun setAutoDownloadAttachments( + recipient: Recipient, + shouldAutoDownloadAttachments: Boolean + ) { + val recipientDb = recipientDatabase + recipientDb.setAutoDownloadAttachments(recipient, shouldAutoDownloadAttachments) + } + override fun setRecipientHash(recipient: Recipient, recipientHash: String?) { - val recipientDb = DatabaseComponent.get(context).recipientDatabase() + val recipientDb = recipientDatabase recipientDb.setRecipientHash(recipient, recipientHash) } override fun getLastUpdated(threadID: Long): Long { - val threadDB = DatabaseComponent.get(context).threadDatabase() + val threadDB = threadDatabase return threadDB.getLastUpdated(threadID) } override fun trimThread(threadID: Long, threadLimit: Int) { - val threadDB = DatabaseComponent.get(context).threadDatabase() + val threadDB = threadDatabase threadDB.trimThread(threadID, threadLimit) } override fun trimThreadBefore(threadID: Long, timestamp: Long) { - val threadDB = DatabaseComponent.get(context).threadDatabase() + val threadDB = threadDatabase threadDB.trimThreadBefore(threadID, timestamp) } override fun getMessageCount(threadID: Long): Long { - val mmsSmsDb = DatabaseComponent.get(context).mmsSmsDatabase() + val mmsSmsDb = mmsSmsDatabase return mmsSmsDb.getConversationCount(threadID) } override fun setPinned(threadID: Long, isPinned: Boolean) { - val threadDB = DatabaseComponent.get(context).threadDatabase() + val threadDB = threadDatabase threadDB.setPinned(threadID, isPinned) val threadRecipient = getRecipientForThread(threadID) ?: return - if (threadRecipient.isLocalNumber) { - val user = configFactory.user ?: return - 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 PRIORITY_VISIBLE - } - } else if (threadRecipient.isGroupRecipient) { - val groups = configFactory.userGroups ?: return - if (threadRecipient.isClosedGroupRecipient) { - threadRecipient.address.serialize() - .let(GroupUtil::doubleDecodeGroupId) - .let(groups::getOrConstructLegacyGroupInfo) - .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) - .let(groups::set) - } else if (threadRecipient.isCommunityRecipient) { - val openGroup = getOpenGroup(threadID) ?: return - val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return - val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( + configFactory.withMutableUserConfigs { configs -> + if (threadRecipient.isLocalNumber) { + configs.userProfile.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) + } else if (threadRecipient.isContactRecipient) { + configs.contacts.upsertContact(threadRecipient.address.serialize()) { priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE - ) - groups.set(newGroupInfo) + } + } else if (threadRecipient.isGroupOrCommunityRecipient) { + when { + threadRecipient.isLegacyGroupRecipient -> { + threadRecipient.address.serialize() + .let(GroupUtil::doubleDecodeGroupId) + .let(configs.userGroups::getOrConstructLegacyGroupInfo) + .copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) + .let(configs.userGroups::set) + } + + threadRecipient.isGroupV2Recipient -> { + val newGroupInfo = configs.userGroups + .getOrConstructClosedGroup(threadRecipient.address.serialize()) + .copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) + configs.userGroups.set(newGroupInfo) + } + + threadRecipient.isCommunityRecipient -> { + val openGroup = getOpenGroup(threadID) ?: return@withMutableUserConfigs + val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) + ?: return@withMutableUserConfigs + val newGroupInfo = configs.userGroups.getOrConstructCommunityInfo( + baseUrl, + room, + Hex.toStringCondensed(pubKeyHex) + ).copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) + configs.userGroups.set(newGroupInfo) + } + } } } - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } override fun isPinned(threadID: Long): Boolean { - val threadDB = DatabaseComponent.get(context).threadDatabase() + val threadDB = threadDatabase return threadDB.isPinned(threadID) } override fun setThreadDate(threadId: Long, newDate: Long) { - val threadDb = DatabaseComponent.get(context).threadDatabase() + val threadDb = threadDatabase threadDb.setDate(threadId, newDate) } override fun getLastLegacyRecipient(threadRecipient: String): String? = - DatabaseComponent.get(context).lokiAPIDatabase().getLastLegacySenderAddress(threadRecipient) + lokiAPIDatabase.getLastLegacySenderAddress(threadRecipient) override fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) { - DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(threadRecipient, senderRecipient) + lokiAPIDatabase.setLastLegacySenderAddress(threadRecipient, senderRecipient) } override fun deleteConversation(threadID: Long) { - val threadDB = DatabaseComponent.get(context).threadDatabase() - val groupDB = DatabaseComponent.get(context).groupDatabase() + val threadDB = threadDatabase + val groupDB = groupDatabase threadDB.deleteConversation(threadID) val recipient = getRecipientForThread(threadID) @@ -1404,18 +1456,40 @@ open class Storage( if (recipient.isContactRecipient || recipient.isCommunityRecipient) return // If we get here then this is a closed group conversation (i.e., recipient.isClosedGroupRecipient) - val volatile = configFactory.convoVolatile ?: return - val groups = configFactory.userGroups ?: return - val groupID = recipient.address.toGroupString() - val closedGroup = getGroup(groupID) - val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) - if (closedGroup != null) { - groupDB.delete(groupID) - volatile.eraseLegacyClosedGroup(groupPublicKey) - groups.eraseLegacyGroup(groupPublicKey) + configFactory.withMutableUserConfigs { configs -> + val volatile = configs.convoInfoVolatile + val groups = configs.userGroups + val groupID = recipient.address.toGroupString() + val closedGroup = getGroup(groupID) + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + if (closedGroup != null) { + groupDB.delete(groupID) + volatile.eraseLegacyClosedGroup(groupPublicKey) + groups.eraseLegacyGroup(groupPublicKey) + } else { + Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}") + } + } + } + + override fun clearMessages(threadID: Long, fromUser: Address?): Boolean { + val threadDb = threadDatabase + if (fromUser == null) { + // this deletes all *from* thread, not deleting the actual thread + smsDatabase.deleteThread(threadID) + mmsDatabase.deleteThread(threadID) // threadDB update called from within } else { - Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}") + // this deletes all *from* thread, not deleting the actual thread + smsDatabase.deleteMessagesFrom(threadID, fromUser.serialize()) + mmsDatabase.deleteMessagesFrom(threadID, fromUser.serialize()) + threadDb.update(threadID, false) } + return true + } + + override fun clearMedia(threadID: Long, fromUser: Address?): Boolean { + mmsDatabase.deleteMediaFor(threadID, fromUser?.serialize()) + return true } override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { @@ -1427,7 +1501,7 @@ open class Storage( } override fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) { - val database = DatabaseComponent.get(context).mmsDatabase() + val database = mmsDatabase val address = fromSerialized(senderPublicKey) val recipient = Recipient.from(context, address, false) @@ -1457,8 +1531,7 @@ open class Storage( ) database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) - - SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) + messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } /** @@ -1476,21 +1549,16 @@ open class Storage( || (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey) ) return - val recipientDb = DatabaseComponent.get(context).recipientDatabase() - val threadDB = DatabaseComponent.get(context).threadDatabase() if (userPublicKey == senderPublicKey) { val requestRecipient = Recipient.from(context, fromSerialized(recipientPublicKey), false) - recipientDb.setApproved(requestRecipient, true) - val threadId = threadDB.getOrCreateThreadIdFor(requestRecipient) - threadDB.setHasSent(threadId, true) + recipientDatabase.setApproved(requestRecipient, true) + val threadId = threadDatabase.getOrCreateThreadIdFor(requestRecipient) + threadDatabase.setHasSent(threadId, true) } else { - val mmsDb = DatabaseComponent.get(context).mmsDatabase() - val smsDb = DatabaseComponent.get(context).smsDatabase() val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) val threadId = getOrCreateThreadIdFor(sender.address) val profile = response.profile if (profile != null) { - val profileManager = SSKEnvironment.shared.profileManager val name = profile.displayName!! if (name.isNotEmpty()) { profileManager.setName(context, sender, name) @@ -1506,16 +1574,16 @@ open class Storage( profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN) } } - threadDB.setHasSent(threadId, true) - val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + threadDatabase.setHasSent(threadId, true) + val mappingDb = blindedIdMappingDatabase val mappings = mutableMapOf() - threadDB.readerFor(threadDB.conversationList).use { reader -> + threadDatabase.readerFor(threadDatabase.conversationList).use { reader -> while (reader.next != null) { val recipient = reader.current.recipient val address = recipient.address.serialize() val blindedId = when { - recipient.isGroupRecipient -> null - recipient.isOpenGroupInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address) + recipient.isGroupOrCommunityRecipient -> null + recipient.isCommunityInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address) else -> address.takeIf { AccountId(it).prefix == IdPrefix.BLINDED } } ?: continue mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let { @@ -1529,13 +1597,22 @@ open class Storage( } mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey)) - val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false)) - mmsDb.updateThreadId(blindedThreadId, threadId) - smsDb.updateThreadId(blindedThreadId, threadId) - threadDB.deleteConversation(blindedThreadId) + val blindedThreadId = threadDatabase.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false)) + mmsDatabase.updateThreadId(blindedThreadId, threadId) + smsDatabase.updateThreadId(blindedThreadId, threadId) + threadDatabase.deleteConversation(blindedThreadId) } setRecipientApproved(sender, true) setRecipientApprovedMe(sender, true) + + // Also update the config about this contact + configFactory.withMutableUserConfigs { + it.contacts.upsertContact(sender.address.serialize()) { + approved = true + approvedMe = true + } + } + val message = IncomingMediaMessage( sender.address, response.sentTimestamp!!, @@ -1554,7 +1631,7 @@ open class Storage( Optional.absent(), Optional.absent() ) - mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) + mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) } } @@ -1564,10 +1641,9 @@ open class Storage( override fun insertMessageRequestResponseFromYou(threadId: Long){ val userPublicKey = getUserPublicKey() ?: return - val mmsDb = DatabaseComponent.get(context).mmsDatabase() val message = IncomingMediaMessage( fromSerialized(userPublicKey), - SnodeAPI.nowWithOffset, + clock.currentTimeMills(), -1, 0, 0, @@ -1583,49 +1659,53 @@ open class Storage( Optional.absent(), Optional.absent() ) - mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false) + mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false) } override fun getRecipientApproved(address: Address): Boolean { - return DatabaseComponent.get(context).recipientDatabase().getApproved(address) + return address.isGroupV2 || recipientDatabase.getApproved(address) } override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { - DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved) + recipientDatabase.setApproved(recipient, approved) if (recipient.isLocalNumber || !recipient.isContactRecipient) return - configFactory.contacts?.upsertContact(recipient.address.serialize()) { - // if the contact wasn't approved before but is approved now, make sure it's visible - if(approved && !this.approved) this.priority = PRIORITY_VISIBLE + configFactory.withMutableUserConfigs { + it.contacts.upsertContact(recipient.address.serialize()) { + // if the contact wasn't approved before but is approved now, make sure it's visible + if(approved && !this.approved) this.priority = PRIORITY_VISIBLE - // update approval - this.approved = approved + // update approval + this.approved = approved + } } } override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) { - DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) + recipientDatabase.setApprovedMe(recipient, approvedMe) if (recipient.isLocalNumber || !recipient.isContactRecipient) return - configFactory.contacts?.upsertContact(recipient.address.serialize()) { - this.approvedMe = approvedMe + configFactory.withMutableUserConfigs { + it.contacts.upsertContact(recipient.address.serialize()) { + this.approvedMe = approvedMe + } } } override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { - val database = DatabaseComponent.get(context).smsDatabase() + val database = smsDatabase val address = fromSerialized(senderPublicKey) val recipient = Recipient.from(context, address, false) - val threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + val threadId = threadDatabase.getOrCreateThreadIdFor(recipient) val expirationConfig = getExpirationConfiguration(threadId) val expiryMode = expirationConfig?.expiryMode?.coerceSendToRead() ?: ExpiryMode.NONE val expiresInMillis = expiryMode.expiryMillis val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt) database.insertCallMessage(callMessage) - SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) + messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } override fun conversationHasOutgoing(userPublicKey: String): Boolean { - val database = DatabaseComponent.get(context).threadDatabase() + val database = threadDatabase val threadId = database.getThreadIdIfExistsFor(userPublicKey) if (threadId == -1L) return false @@ -1634,27 +1714,27 @@ open class Storage( } override fun getLastInboxMessageId(server: String): Long? { - return DatabaseComponent.get(context).lokiAPIDatabase().getLastInboxMessageId(server) + return lokiAPIDatabase.getLastInboxMessageId(server) } override fun setLastInboxMessageId(server: String, messageId: Long) { - DatabaseComponent.get(context).lokiAPIDatabase().setLastInboxMessageId(server, messageId) + lokiAPIDatabase.setLastInboxMessageId(server, messageId) } override fun removeLastInboxMessageId(server: String) { - DatabaseComponent.get(context).lokiAPIDatabase().removeLastInboxMessageId(server) + lokiAPIDatabase.removeLastInboxMessageId(server) } override fun getLastOutboxMessageId(server: String): Long? { - return DatabaseComponent.get(context).lokiAPIDatabase().getLastOutboxMessageId(server) + return lokiAPIDatabase.getLastOutboxMessageId(server) } override fun setLastOutboxMessageId(server: String, messageId: Long) { - DatabaseComponent.get(context).lokiAPIDatabase().setLastOutboxMessageId(server, messageId) + lokiAPIDatabase.setLastOutboxMessageId(server, messageId) } override fun removeLastOutboxMessageId(server: String) { - DatabaseComponent.get(context).lokiAPIDatabase().removeLastOutboxMessageId(server) + lokiAPIDatabase.removeLastOutboxMessageId(server) } override fun getOrCreateBlindedIdMapping( @@ -1663,7 +1743,7 @@ open class Storage( serverPublicKey: String, fromOutbox: Boolean ): BlindedIdMapping { - val db = DatabaseComponent.get(context).blindedIdMappingDatabase() + val db = blindedIdMappingDatabase val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey) if (mapping.accountId != null) { return mapping @@ -1694,19 +1774,16 @@ open class Storage( val messageId = if (localId != null && localId > 0 && isMms != null) { // bail early is the message is marked as deleted - val messagingDatabase: MessagingDatabase = if (isMms == true) DatabaseComponent.get(context).mmsDatabase() - else DatabaseComponent.get(context).smsDatabase() + val messagingDatabase: MessagingDatabase = if (isMms == true) mmsDatabase else smsDatabase if(messagingDatabase.getMessageRecord(localId)?.isDeleted == true) return MessageId(localId, isMms) } else if (timestamp != null && timestamp > 0) { - val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(timestamp) ?: return + val messageRecord = mmsSmsDatabase.getMessageForTimestamp(timestamp) ?: return if (messageRecord.isDeleted) return - MessageId(messageRecord.id, messageRecord.isMms) } else return - - DatabaseComponent.get(context).reactionDatabase().addReaction( + reactionDatabase.addReaction( messageId, ReactionRecord( messageId = messageId.id, @@ -1724,13 +1801,13 @@ open class Storage( } override fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) { - val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(messageTimestamp) ?: return + val messageRecord = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp) ?: return val messageId = MessageId(messageRecord.id, messageRecord.isMms) - DatabaseComponent.get(context).reactionDatabase().deleteReaction(emoji, messageId, author, notifyUnread) + reactionDatabase.deleteReaction(emoji, messageId, author, notifyUnread) } override fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) { - val database = DatabaseComponent.get(context).reactionDatabase() + val database = reactionDatabase var reaction = database.getReactionFor(message.sentTimestamp!!, sender) ?: return if (openGroupSentTimestamp != -1L) { addReceivedMessageTimestamp(openGroupSentTimestamp) @@ -1746,81 +1823,100 @@ open class Storage( } override fun deleteReactions(messageId: Long, mms: Boolean) { - DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) + reactionDatabase.deleteMessageReactions(MessageId(messageId, mms)) } override fun deleteReactions(messageIds: List, mms: Boolean) { - DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions( + reactionDatabase.deleteMessageReactions( messageIds.map { MessageId(it, mms) } ) } override fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean) { - val recipientDb = DatabaseComponent.get(context).recipientDatabase() + val recipientDb = recipientDatabase recipientDb.setBlocked(recipients, isBlocked) - recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient -> - configFactory.contacts?.upsertContact(recipient.address.serialize()) { - this.blocked = isBlocked + configFactory.withMutableUserConfigs { configs -> + recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient -> + configs.contacts.upsertContact(recipient.address.serialize()) { + this.blocked = isBlocked + } } } - val contactsConfig = configFactory.contacts ?: return - if (contactsConfig.needsPush() && !fromConfigUpdate) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } override fun blockedContacts(): List { - val recipientDb = DatabaseComponent.get(context).recipientDatabase() + val recipientDb = recipientDatabase return recipientDb.blockedContacts } override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? { val recipient = getRecipientForThread(threadId) ?: return null - val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null + val dbExpirationMetadata = expirationConfigurationDatabase.getExpirationConfiguration(threadId) return when { - recipient.isLocalNumber -> configFactory.user?.getNtsExpiry() + recipient.isLocalNumber -> configFactory.withUserConfigs { it.userProfile.getNtsExpiry() } recipient.isContactRecipient -> { // read it from contacts config if exists recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) } - ?.let { configFactory.contacts?.get(it)?.expiryMode } + ?.let { configFactory.withUserConfigs { configs -> configs.contacts.get(it)?.expiryMode } } + } + recipient.isGroupV2Recipient -> { + configFactory.withGroupConfigs(AccountId(recipient.address.serialize())) { configs -> + configs.groupInfo.getExpiryTimer() + }.let { + if (it == 0L) ExpiryMode.NONE else ExpiryMode.AfterSend(it) + } } - recipient.isClosedGroupRecipient -> { + recipient.isLegacyGroupRecipient -> { // read it from group config if exists GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) - .let { configFactory.userGroups?.getLegacyGroupInfo(it) } + .let { id -> configFactory.withUserConfigs { it.userGroups.getLegacyGroupInfo(id) } } ?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE } } else -> null - }?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) } + }?.let { ExpirationConfiguration( + threadId, + it, + // This will be 0L for new closed groups, apparently we don't need this anymore? + dbExpirationMetadata?.updatedTimestampMs ?: 0L + ) } } override fun setExpirationConfiguration(config: ExpirationConfiguration) { val recipient = getRecipientForThread(config.threadId) ?: return - val expirationDb = DatabaseComponent.get(context).expirationConfigurationDatabase() + val expirationDb = expirationConfigurationDatabase val currentConfig = expirationDb.getExpirationConfiguration(config.threadId) if (currentConfig != null && currentConfig.updatedTimestampMs >= config.updatedTimestampMs) return val expiryMode = config.expiryMode if (expiryMode == ExpiryMode.NONE) { // Clear the legacy recipients on updating config to be none - DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null) + lokiAPIDatabase.setLastLegacySenderAddress(recipient.address.serialize(), null) } - if (recipient.isClosedGroupRecipient) { - val userGroups = configFactory.userGroups ?: return + if (recipient.isLegacyGroupRecipient) { val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address) - val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey) - ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return - userGroups.set(groupInfo) + + configFactory.withMutableUserConfigs { + val groupInfo = it.userGroups.getLegacyGroupInfo(groupPublicKey) + ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return@withMutableUserConfigs + it.userGroups.set(groupInfo) + } + } else if (recipient.isGroupV2Recipient) { + val groupSessionId = AccountId(recipient.address.serialize()) + configFactory.withMutableGroupConfigs(groupSessionId) { configs -> + configs.groupInfo.setExpiryTimer(expiryMode.expirySeconds) + } + } else if (recipient.isLocalNumber) { - val user = configFactory.user ?: return - user.setNtsExpiry(expiryMode) + configFactory.withMutableUserConfigs { + it.userProfile.setNtsExpiry(expiryMode) + } } else if (recipient.isContactRecipient) { - val contacts = configFactory.contacts ?: return - - val contact = contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return - contacts.set(contact) + configFactory.withMutableUserConfigs { + val contact = it.contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return@withMutableUserConfigs + it.contacts.set(contact) + } } expirationDb.setExpirationConfiguration( config.run { copy(expiryMode = expiryMode) } @@ -1829,7 +1925,7 @@ open class Storage( override fun getExpiringMessages(messageIds: List): List> { val expiringMessages = mutableListOf>() - val smsDb = DatabaseComponent.get(context).smsDatabase() + val smsDb = smsDatabase smsDb.readerFor(smsDb.expirationNotStartedMessages).use { reader -> while (reader.next != null) { if (messageIds.isEmpty() || reader.current.id in messageIds) { @@ -1837,7 +1933,7 @@ open class Storage( } } } - val mmsDb = DatabaseComponent.get(context).mmsDatabase() + val mmsDb = mmsDatabase mmsDb.expireNotStartedMessages.use { reader -> while (reader.next != null) { if (messageIds.isEmpty() || reader.current.id in messageIds) { @@ -1853,11 +1949,11 @@ open class Storage( threadID: Long, disappearingState: Recipient.DisappearingState ) { - val threadDb = DatabaseComponent.get(context).threadDatabase() - val lokiDb = DatabaseComponent.get(context).lokiAPIDatabase() + val threadDb = threadDatabase + val lokiDb = lokiAPIDatabase val recipient = threadDb.getRecipientForThreadId(threadID) ?: return val recipientAddress = recipient.address.serialize() - DatabaseComponent.get(context).recipientDatabase() + recipientDatabase .setDisappearingState(recipient, disappearingState); val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress) val currentExpiry = getExpirationConfiguration(threadID) @@ -1870,11 +1966,3 @@ open class Storage( } } } - -/** - * Truncate a string to a specified number of bytes - * - * This could split multi-byte characters/emojis. - */ -private fun String.truncate(sizeInBytes: Int): String = - toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this 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 5cc8bd06f76..f9b204a8368 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.COMMUNITY_PREFIX; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; @@ -124,10 +124,15 @@ public interface ConversationThreadUpdateListener { .map(columnName -> TABLE_NAME + "." + columnName) .toList(); - private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION), - Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)), - Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)) - .toList(); + private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = + // wew + Stream.concat(Stream.concat(Stream.concat( + Stream.of(TYPED_THREAD_PROJECTION), + Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)), + Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)), + Stream.of(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId) + ) + .toList(); public static String getCreatePinnedCommand() { return "ALTER TABLE "+ TABLE_NAME + " " + @@ -289,7 +294,7 @@ public void trimThread(long threadId, int length) { Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); - DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); + DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate, false); update(threadId, false); notifyConversationListeners(threadId); @@ -303,7 +308,7 @@ public void trimThread(long threadId, int length) { public void trimThreadBefore(long threadId, long timestamp) { Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); - DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); + DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false); update(threadId, false); notifyConversationListeners(threadId); } @@ -438,32 +443,6 @@ public Cursor getRecentConversationList(int limit) { 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; @@ -502,13 +481,15 @@ public Cursor getBlindedConversationList() { } 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 '" + COMMUNITY_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 '" + COMMUNITY_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"; @@ -684,7 +665,7 @@ public long getOrCreateThreadIdFor(Recipient recipient, int distributionType) { threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); } else { DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true); - threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); + threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupOrCommunityRecipient(), distributionType); created = true; } if (created && updateListener != null) { @@ -795,7 +776,7 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); MarkReadReceiver.process(context, messages); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); + ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } @@ -834,12 +815,14 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); String query = "SELECT " + projection + " 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 " + where + - " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC"; + " 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 + + " LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable + + " ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId + + " WHERE " + where + + " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC"; if (limit > 0) { query += " LIMIT " + limit; @@ -917,6 +900,7 @@ public ThreadRecord getCurrent() { long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); Uri snippetUri = getSnippetUri(cursor); boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0; + String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId)); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -934,7 +918,7 @@ public ThreadRecord getCurrent() { return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count, unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type, - distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned); + distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin); } private @Nullable Uri getSnippetUri(Cursor cursor) { 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 b6ebd6db84e..0a08ccd3571 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 @@ -90,9 +90,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV44 = 65; private static final int lokiV45 = 66; private static final int lokiV46 = 67; + private static final int lokiV47 = 68; + private static final int lokiV48 = 69; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV46; + private static final int DATABASE_VERSION = lokiV48; 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"; @@ -362,6 +364,11 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); + + db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand()); + db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand()); + db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger()); } @Override @@ -628,6 +635,16 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); } + if (oldVersion < lokiV47) { + db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand()); + db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand()); + } + + if (oldVersion < lokiV48) { + db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 5f6257ee924..062b4f1e314 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -24,6 +24,7 @@ import androidx.annotation.NonNull; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage; import org.session.libsession.messaging.utilities.UpdateMessageBuilder; @@ -118,10 +119,19 @@ public boolean isUpdate() { public CharSequence getDisplayBody(@NonNull Context context) { if (isGroupUpdateMessage()) { UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); + if (updateMessageData == null) { + return ""; + } + + return new SpannableString(UpdateMessageBuilder.buildGroupUpdateMessage( + context, + updateMessageData, + MessagingModuleConfiguration.getShared().getConfigFactory(), + isOutgoing()) + ); } else if (isExpirationTimerUpdate()) { int seconds = (int) (getExpiresIn() / 1000); - boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient(); + boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupOrCommunityRecipient(); return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, isGroup, getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted)); } else if (isDataExtractionNotification()) { if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 019eea64eae..00641c9a8db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -32,6 +32,9 @@ import android.text.style.StyleSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import org.session.libsession.messaging.utilities.UpdateMessageBuilder; +import org.session.libsession.messaging.utilities.UpdateMessageData; import com.squareup.phrase.Phrase; import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.TextSecurePreferences; @@ -52,39 +55,41 @@ */ public class ThreadRecord extends DisplayRecord { - private @Nullable final Uri snippetUri; - public @Nullable final MessageRecord lastMessage; - private final long count; - private final int unreadCount; - private final int unreadMentionCount; - private final int distributionType; - private final boolean archived; - private final long expiresIn; - private final long lastSeen; - private final boolean pinned; - private final int initialRecipientHash; - private final long dateSent; - - public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, - @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, - int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, - long snippetType, int distributionType, boolean archived, long expiresIn, - long lastSeen, int readReceiptCount, boolean pinned) - { - super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); - this.snippetUri = snippetUri; - this.lastMessage = lastMessage; - this.count = count; - this.unreadCount = unreadCount; - this.unreadMentionCount = unreadMentionCount; - this.distributionType = distributionType; - this.archived = archived; - this.expiresIn = expiresIn; - this.lastSeen = lastSeen; - this.pinned = pinned; - this.initialRecipientHash = recipient.hashCode(); - this.dateSent = date; - } + private @Nullable final Uri snippetUri; + public @Nullable final MessageRecord lastMessage; + private final long count; + private final int unreadCount; + private final int unreadMentionCount; + private final int distributionType; + private final boolean archived; + private final long expiresIn; + private final long lastSeen; + private final boolean pinned; + private final int initialRecipientHash; + private final String invitingAdminId; + private final long dateSent; + + public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, + @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, + int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, + long snippetType, int distributionType, boolean archived, long expiresIn, + long lastSeen, int readReceiptCount, boolean pinned, String invitingAdminId) + { + super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); + this.snippetUri = snippetUri; + this.lastMessage = lastMessage; + this.count = count; + this.unreadCount = unreadCount; + this.unreadMentionCount = unreadMentionCount; + this.distributionType = distributionType; + this.archived = archived; + this.expiresIn = expiresIn; + this.lastSeen = lastSeen; + this.pinned = pinned; + this.initialRecipientHash = recipient.hashCode(); + this.invitingAdminId = invitingAdminId; + this.dateSent = date; + } public @Nullable Uri getSnippetUri() { return snippetUri; @@ -107,7 +112,7 @@ public CharSequence getDisplayBody(@NonNull Context context) { return ""; } else if (isGroupUpdateMessage()) { - return context.getString(R.string.groupUpdated); + return lastMessage.getDisplayBody(context).toString(); } else if (isOpenGroupInvitation()) { return context.getString(R.string.communityInvitation); } else if (MmsSmsColumns.Types.isLegacyType(type)) { @@ -225,4 +230,30 @@ else if(lastMessage != null){ public boolean isPinned() { return pinned; } public int getInitialRecipientHash() { return initialRecipientHash; } + + public boolean isLeavingGroup() { + if (isGroupUpdateMessage()) { + String body = getBody(); + if (!body.isEmpty()) { + UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body); + return updateMessageData.isGroupLeavingKind(); + } + } + return false; + } + + public boolean isErrorLeavingGroup() { + if (isGroupUpdateMessage()) { + String body = getBody(); + if (!body.isEmpty()) { + UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body); + return updateMessageData.isGroupErrorQuitKind(); + } + } + return false; + } + + public String getInvitingAdminId() { + return invitingAdminId; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 750b3e20c7e..781676788e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -1,25 +1,17 @@ package org.thoughtcrime.securesms.debugmenu import android.app.Application -import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import network.loki.messenger.R -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext -import org.session.libsession.utilities.Environment -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import javax.inject.Inject @HiltViewModel @@ -75,11 +67,6 @@ class DebugMenuViewModel @Inject constructor( // clear remote and local data, then restart the app viewModelScope.launch { - try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get() - } catch (e: Exception) { - // we can ignore fails here as we might be switching environments before the user gets a public key - } ApplicationContext.getInstance(application).clearAllData().let { success -> if(success){ // save the environment 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 a9a72e76657..a06cd9404fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -1,24 +1,70 @@ package org.thoughtcrime.securesms.dependencies +import android.content.Context +import android.widget.Toast 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.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.Toaster +import org.thoughtcrime.securesms.groups.GroupManagerV2Impl +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier +import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository +import org.thoughtcrime.securesms.sskenvironment.ProfileManager +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -abstract class AppModule { +class AppModule { + + @Provides + @Singleton + fun provideMessageNotifier(): MessageNotifier { + return OptimizedMessageNotifier(DefaultMessageNotifier()) + } +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class AppBindings { @Binds abstract fun bindTextSecurePreferences(preferences: AppTextSecurePreferences): TextSecurePreferences @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository + + @Binds + abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2 + + @Binds + abstract fun bindProfileManager(profileManager: ProfileManager): SSKEnvironment.ProfileManagerProtocol + + @Binds + abstract fun bindConfigFactory(configFactory: ConfigFactory): ConfigFactoryProtocol + +} + +@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 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 da15c2f6b45..8e0c7357708 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 12b7960595b..f0191615e24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -1,211 +1,374 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context -import android.os.Trace +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow 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.MutableContacts +import network.loki.messenger.libsession_util.MutableConversationVolatileConfig +import network.loki.messenger.libsession_util.MutableUserGroupsConfig +import network.loki.messenger.libsession_util.MutableUserProfile 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.ConfigPush +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.Sodium +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.control.ConfigurationMessage +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.ConfigFactoryUpdateListener +import org.session.libsession.utilities.ConfigMessage +import org.session.libsession.utilities.ConfigPushResult +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.GroupConfigs +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.MutableGroupConfigs +import org.session.libsession.utilities.MutableUserConfigs import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.UserConfigs +import org.session.libsession.utilities.getGroup +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.ConfigDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.database.ConfigVariant +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.groups.GroupManager -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import java.util.concurrent.locks.ReentrantReadWriteLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.read +import kotlin.concurrent.write -class ConfigFactory( - private val context: Context, + +@Singleton +class ConfigFactory @Inject constructor( + @ApplicationContext private val context: Context, private val configDatabase: ConfigDatabase, - private val maybeGetUserInfo: () -> Pair? -) : - ConfigFactoryProtocol { + private val threadDb: ThreadDatabase, + private val lokiThreadDatabase: LokiThreadDatabase, + private val storage: Lazy, + private val textSecurePreferences: TextSecurePreferences, + private val clock: SnodeClock, +) : ConfigFactoryProtocol { companion object { // This is a buffer period within which we will process messages which would result in a // config change, any message which would normally result in a config change which was sent // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have // it's changes applied (control text will still be added though) - val configChangeBufferPeriod: Long = (2 * 60 * 1000) + private const val CONFIG_CHANGE_BUFFER_PERIOD: Long = 2 * 60 * 1000L } - fun keyPairChanged() { // this should only happen restoring or clearing data - _userConfig?.free() - _contacts?.free() - _convoVolatileConfig?.free() - _userConfig = null - _contacts = null - _convoVolatileConfig = null + init { + System.loadLibrary("session_util") } - private val userLock = Object() - private var _userConfig: UserProfile? = null - private val contactsLock = Object() - private var _contacts: Contacts? = null - private val convoVolatileLock = Object() - private var _convoVolatileConfig: ConversationVolatileConfig? = null - private val userGroupsLock = Object() - private var _userGroups: UserGroupsConfig? = null + private val userConfigs = HashMap>() + private val groupConfigs = HashMap>() + + private val _configUpdateNotifications = MutableSharedFlow( + extraBufferCapacity = 5, // The notifications are normally important so we can afford to buffer a few + onBufferOverflow = BufferOverflow.SUSPEND + ) + override val configUpdateNotifications get() = _configUpdateNotifications + + private fun requiresCurrentUserAccountId(): AccountId = + AccountId(requireNotNull(textSecurePreferences.getLocalNumber()) { + "No logged in user" + }) + + private fun requiresCurrentUserED25519SecKey(): ByteArray = + requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) { + "No logged in user" + } - private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) } + private fun ensureUserConfigsInitialized(): Pair { + val userAccountId = requiresCurrentUserAccountId() - private val listeners: MutableList = mutableListOf() - fun registerListener(listener: ConfigFactoryUpdateListener) { - listeners += listener + return synchronized(userConfigs) { + userConfigs.getOrPut(userAccountId) { + ReentrantReadWriteLock() to UserConfigsImpl( + userEd25519SecKey = requiresCurrentUserED25519SecKey(), + userAccountId = userAccountId, + threadDb = threadDb, + configDatabase = configDatabase, + storage = storage.get() + ) + } + } } - fun unregisterListener(listener: ConfigFactoryUpdateListener) { - listeners -= listener + private fun ensureGroupConfigsInitialized(groupId: AccountId): Pair { + val groupAdminKey = getGroup(groupId)?.adminKey + return synchronized(groupConfigs) { + groupConfigs.getOrPut(groupId) { + ReentrantReadWriteLock() to GroupConfigsImpl( + userEd25519SecKey = requiresCurrentUserED25519SecKey(), + groupAccountId = groupId, + groupAdminKey = groupAdminKey, + configDatabase = configDatabase + ) + } + } } - private inline fun synchronizedWithLog(lock: Any, body: ()->T): T { - Trace.beginSection("synchronizedWithLog") - val result = synchronized(lock) { - body() + override fun withUserConfigs(cb: (UserConfigs) -> T): T { + val (lock, configs) = ensureUserConfigsInitialized() + return lock.read { + cb(configs) } - Trace.endSection() - return result } - override val user: UserProfile? - get() = synchronizedWithLog(userLock) { - if (_userConfig == null) { - val (secretKey, publicKey) = maybeGetUserInfo() ?: return null - val userDump = configDatabase.retrieveConfigAndHashes( - SharedConfigMessage.Kind.USER_PROFILE.name, - publicKey - ) - _userConfig = if (userDump != null) { - UserProfile.newInstance(secretKey, userDump) - } else { - ConfigurationMessageUtilities.generateUserProfileConfigDump()?.let { dump -> - UserProfile.newInstance(secretKey, dump) - } ?: UserProfile.newInstance(secretKey) + /** + * Perform an operation on the user configs, and notify listeners if the configs were changed. + * + * @param cb A function that takes a [UserConfigsImpl] and returns a pair of the result of the operation and a boolean indicating if the configs were changed. + */ + private fun doWithMutableUserConfigs(cb: (UserConfigsImpl) -> Pair>): T { + val (lock, configs) = ensureUserConfigsInitialized() + val (result, changed) = lock.write { + cb(configs) + } + + if (changed.isNotEmpty()) { + for (notification in changed) { + if (!_configUpdateNotifications.tryEmit(notification)) { + Log.e("ConfigFactory", "Unable to deliver config update notification") } } - _userConfig } - override val contacts: Contacts? - get() = synchronizedWithLog(contactsLock) { - if (_contacts == null) { - val (secretKey, publicKey) = maybeGetUserInfo() ?: return null - val contactsDump = configDatabase.retrieveConfigAndHashes( - SharedConfigMessage.Kind.CONTACTS.name, - publicKey - ) - _contacts = if (contactsDump != null) { - Contacts.newInstance(secretKey, contactsDump) - } else { - ConfigurationMessageUtilities.generateContactConfigDump()?.let { dump -> - Contacts.newInstance(secretKey, dump) - } ?: Contacts.newInstance(secretKey) - } + return result + } + + override fun mergeUserConfigs( + userConfigType: UserConfigType, + messages: List + ) { + if (messages.isEmpty()) { + return + } + + val toDump = doWithMutableUserConfigs { configs -> + val config = when (userConfigType) { + UserConfigType.CONTACTS -> configs.contacts + UserConfigType.USER_PROFILE -> configs.userProfile + UserConfigType.CONVO_INFO_VOLATILE -> configs.convoInfoVolatile + UserConfigType.USER_GROUPS -> configs.userGroups } - _contacts + + // Merge the list of config messages, we'll be told which messages have been merged + // and we will then find out which message has the max timestamp + val maxTimestamp = config.merge(messages.map { it.hash to it.data }.toTypedArray()) + .asSequence() + .mapNotNull { hash -> messages.firstOrNull { it.hash == hash } } + .maxOfOrNull { it.timestamp } + + maxTimestamp?.let { + (config.dump() to it) to + listOf(ConfigUpdateNotification.UserConfigsMerged(userConfigType, it)) + } ?: (null to emptyList()) } - override val convoVolatile: ConversationVolatileConfig? - get() = synchronizedWithLog(convoVolatileLock) { - if (_convoVolatileConfig == null) { - val (secretKey, publicKey) = maybeGetUserInfo() ?: return null - val convoDump = configDatabase.retrieveConfigAndHashes( - SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name, - publicKey - ) - _convoVolatileConfig = if (convoDump != null) { - ConversationVolatileConfig.newInstance(secretKey, convoDump) - } else { - ConfigurationMessageUtilities.generateConversationVolatileDump(context) - ?.let { dump -> - ConversationVolatileConfig.newInstance(secretKey, dump) - } ?: ConversationVolatileConfig.newInstance(secretKey) - } + // Dump now regardless so we can save the timestamp to the database + if (toDump != null) { + val (dump, timestamp) = toDump + configDatabase.storeConfig( + variant = userConfigType.configVariant, + publicKey = requiresCurrentUserAccountId().hexString, + data = dump, + timestamp = timestamp + ) + } + } + + override fun withMutableUserConfigs(cb: (MutableUserConfigs) -> T): T { + return doWithMutableUserConfigs { + val result = cb(it) + + val changed = if (it.userGroups.dirty() || + it.convoInfoVolatile.dirty() || + it.userProfile.dirty() || + it.contacts.dirty()) { + listOf(ConfigUpdateNotification.UserConfigsModified) + } else { + emptyList() } - _convoVolatileConfig + + result to changed } + } - override val userGroups: UserGroupsConfig? - get() = synchronizedWithLog(userGroupsLock) { - if (_userGroups == null) { - val (secretKey, publicKey) = maybeGetUserInfo() ?: return null - val userGroupsDump = configDatabase.retrieveConfigAndHashes( - SharedConfigMessage.Kind.GROUPS.name, - publicKey - ) - _userGroups = if (userGroupsDump != null) { - UserGroupsConfig.Companion.newInstance(secretKey, userGroupsDump) - } else { - ConfigurationMessageUtilities.generateUserGroupDump(context)?.let { dump -> - UserGroupsConfig.Companion.newInstance(secretKey, dump) - } ?: UserGroupsConfig.newInstance(secretKey) - } + override fun withGroupConfigs(groupId: AccountId, cb: (GroupConfigs) -> T): T { + val (lock, configs) = ensureGroupConfigsInitialized(groupId) + + return lock.read { + cb(configs) + } + } + + private fun doWithMutableGroupConfigs( + groupId: AccountId, + recreateConfigInstances: Boolean, + cb: (GroupConfigsImpl) -> Pair): T { + if (recreateConfigInstances) { + synchronized(groupConfigs) { + groupConfigs.remove(groupId) } - _userGroups } - override fun getUserConfigs(): List = - listOfNotNull(user, contacts, convoVolatile, userGroups) + val (lock, configs) = ensureGroupConfigsInitialized(groupId) + val (result, changed) = lock.write { + cb(configs) + } + if (changed) { + if (!_configUpdateNotifications.tryEmit(ConfigUpdateNotification.GroupConfigsUpdated(groupId))) { + Log.e("ConfigFactory", "Unable to deliver group update notification") + } + } - 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) + return result } - 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) + override fun withMutableGroupConfigs( + groupId: AccountId, + recreateConfigInstances: Boolean, + cb: (MutableGroupConfigs) -> T + ): T { + return doWithMutableGroupConfigs(recreateConfigInstances = recreateConfigInstances, groupId = groupId) { + cb(it) to it.dumpIfNeeded(clock) + } } - private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) { - val dumped = convoVolatile?.dump() ?: return - val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig( - SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name, - publicKey, - dumped, - timestamp - ) + override fun removeGroup(groupId: AccountId) { + withMutableUserConfigs { + it.userGroups.eraseClosedGroup(groupId.hexString) + it.convoInfoVolatile.eraseClosedGroup(groupId.hexString) + } + + configDatabase.deleteGroupConfigs(groupId) } - 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) + override fun decryptForUser( + encoded: ByteArray, + domain: String, + closedGroupSessionId: AccountId + ): ByteArray? { + return Sodium.decryptForMultipleSimple( + encoded = encoded, + ed25519SecretKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) { + "No logged in user" + }, + domain = domain, + senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes) + ) } - override fun persist(forConfigObject: ConfigBase, timestamp: Long) { - try { - listeners.forEach { listener -> - listener.notifyUpdates(forConfigObject, timestamp) - } - when (forConfigObject) { - is UserProfile -> persistUserConfigDump(timestamp) - is Contacts -> persistContactsConfigDump(timestamp) - is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) - is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp) - else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") + override fun mergeGroupConfigMessages( + groupId: AccountId, + keys: List, + info: List, + members: List + ) { + doWithMutableGroupConfigs(groupId, false) { configs -> + // Keys must be loaded first as they are used to decrypt the other config messages + val keysLoaded = keys.fold(false) { acc, msg -> + configs.groupKeys.loadKey(msg.data, msg.hash, msg.timestamp, configs.groupInfo.pointer, configs.groupMembers.pointer) || acc } - } catch (e: Exception) { - Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e) + + val infoMerged = info.isNotEmpty() && + configs.groupInfo.merge(info.map { it.hash to it.data }.toTypedArray()).isNotEmpty() + + val membersMerged = members.isNotEmpty() && + configs.groupMembers.merge(members.map { it.hash to it.data }.toTypedArray()).isNotEmpty() + + configs.dumpIfNeeded(clock) + + Unit to (keysLoaded || infoMerged || membersMerged) } } - override fun getConfigTimestamp(forConfigObject: ConfigBase, publicKey: String): Long { - val variant = when (forConfigObject) { - is UserProfile -> SharedConfigMessage.Kind.USER_PROFILE.name - is Contacts -> SharedConfigMessage.Kind.CONTACTS.name - is ConversationVolatileConfig -> SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name - is UserGroupsConfig -> SharedConfigMessage.Kind.GROUPS.name - else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") + override fun confirmUserConfigsPushed( + contacts: Pair?, + userProfile: Pair?, + convoInfoVolatile: Pair?, + userGroups: Pair? + ) { + if (contacts == null && userProfile == null && convoInfoVolatile == null && userGroups == null) { + return + } + + // Confirm push for the configs and gather the dumped data to be saved into the db. + // For this operation, we will no notify the users as there won't be any real change in terms + // of the displaying data. + val dump = doWithMutableUserConfigs { configs -> + sequenceOf(contacts, userProfile, convoInfoVolatile, userGroups) + .zip( + sequenceOf( + UserConfigType.CONTACTS to configs.contacts, + UserConfigType.USER_PROFILE to configs.userProfile, + UserConfigType.CONVO_INFO_VOLATILE to configs.convoInfoVolatile, + UserConfigType.USER_GROUPS to configs.userGroups + ) + ) + .filter { (push, _) -> push != null } + .onEach { (push, config) -> config.second.confirmPushed(push!!.first.seqNo, push.second.hash) } + .map { (push, config) -> + Triple(config.first.configVariant, config.second.dump(), push!!.second.timestamp) + }.toList() to emptyList() } - return configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + // We need to persist the data to the database to save timestamp after the push + val userAccountId = requiresCurrentUserAccountId() + for ((variant, data, timestamp) in dump) { + configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp) + } + } + + override fun confirmGroupConfigsPushed( + groupId: AccountId, + members: Pair?, + info: Pair?, + keysPush: ConfigPushResult? + ) { + if (members == null && info == null && keysPush == null) { + return + } + + doWithMutableGroupConfigs(groupId, false) { configs -> + members?.let { (push, result) -> configs.groupMembers.confirmPushed(push.seqNo, result.hash) } + info?.let { (push, result) -> configs.groupInfo.confirmPushed(push.seqNo, result.hash) } + keysPush?.let { (hash, timestamp) -> + val pendingConfig = configs.groupKeys.pendingConfig() + if (pendingConfig != null) { + configs.groupKeys.loadKey(pendingConfig, hash, timestamp, configs.groupInfo.pointer, configs.groupMembers.pointer) + } + } + + configs.dumpIfNeeded(clock) + + Unit to true + } } override fun conversationInConfig( @@ -214,41 +377,368 @@ class ConfigFactory( openGroupId: String?, visibleOnly: Boolean ): Boolean { - val (_, userPublicKey) = maybeGetUserInfo() ?: return true + val userPublicKey = storage.get().getUserPublicKey() ?: return false 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 = 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) + return withUserConfigs { + it.userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null + } + } else if (groupPublicKey != null) { + // Not handling the `hidden` behaviour for legacy groups so just indicate the existence + return withUserConfigs { + if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) { + it.userGroups.getClosedGroup(groupPublicKey) != null + } else { + it.userGroups.getLegacyGroupInfo(groupPublicKey) != null + } + } + } else if (publicKey == userPublicKey) { + return withUserConfigs { + !visibleOnly || it.userProfile.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN + } + } else if (publicKey != null) { + return withUserConfigs { + (!visibleOnly || it.contacts.get(publicKey)?.priority != ConfigBase.PRIORITY_HIDDEN) + } + } else { + return false } - 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) + override fun canPerformChange( + variant: String, + publicKey: String, + changeTimestampMs: Long + ): Boolean { + val lastUpdateTimestampMs = + configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + + // Ensure the change occurred after the last config message was handled (minus the buffer period) + return (changeTimestampMs >= (lastUpdateTimestampMs - CONFIG_CHANGE_BUFFER_PERIOD)) + } + + override fun getConfigTimestamp(userConfigType: UserConfigType, publicKey: String): Long { + return configDatabase.retrieveConfigLastUpdateTimestamp(userConfigType.configVariant, publicKey) + } + + override fun getGroupAuth(groupId: AccountId): SwarmAuth? { + val (adminKey, authData) = withUserConfigs { + val group = it.userGroups.getClosedGroup(groupId.hexString) + group?.adminKey to group?.authData } - else if (publicKey == userPublicKey) { - val user = user ?: return false - return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) + return if (adminKey != null) { + OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) + } else if (authData != null) { + GroupSubAccountSwarmAuth(groupId, this, authData) + } else { + null } - else if (publicKey != null) { - val contacts = contacts ?: return false - val targetContact = contacts.get(publicKey) ?: return false + } - return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN) + fun clearAll() { + synchronized(userConfigs) { + userConfigs.clear() } - return false + synchronized(groupConfigs) { + groupConfigs.clear() + } } - override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { - val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + private class GroupSubAccountSwarmAuth( + override val accountId: AccountId, + val factory: ConfigFactory, + val authData: ByteArray, + ) : SwarmAuth { + override val ed25519PublicKeyHex: String? + get() = null - // Ensure the change occurred after the last config message was handled (minus the buffer period) - return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod)) + override fun sign(data: ByteArray): Map { + return factory.withGroupConfigs(accountId) { + val auth = it.groupKeys.subAccountSign(data, authData) + buildMap { + put("subaccount", auth.subAccount) + put("subaccount_sig", auth.subAccountSig) + put("signature", auth.signature) + } + } + } + + override fun signForPushRegistry(data: ByteArray): Map { + return factory.withGroupConfigs(accountId) { + val auth = it.groupKeys.subAccountSign(data, authData) + buildMap { + put("subkey_tag", auth.subAccount) + put("signature", auth.signature) + } + } + } + } +} + +private val UserConfigType.configVariant: ConfigVariant + get() = when (this) { + UserConfigType.CONTACTS -> ConfigDatabase.CONTACTS_VARIANT + UserConfigType.USER_PROFILE -> ConfigDatabase.USER_PROFILE_VARIANT + UserConfigType.CONVO_INFO_VOLATILE -> ConfigDatabase.CONVO_INFO_VARIANT + UserConfigType.USER_GROUPS -> ConfigDatabase.USER_GROUPS_VARIANT + } + +/** + * Sync group data from our local database + */ +private fun MutableUserGroupsConfig.initFrom(storage: StorageProtocol) { + storage + .getAllOpenGroups() + .values + .asSequence() + .mapNotNull { openGroup -> + val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null + val pubKeyHex = Hex.toStringCondensed(pubKey) + val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex) + val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null + val isPinned = storage.isPinned(threadId) + GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0) + } + .forEach(this::set) + + storage + .getAllGroups(includeInactive = false) + .asSequence().filter { it.isLegacyGroup && it.isActive && it.members.size > 1 } + .mapNotNull { group -> + val groupAddress = Address.fromSerialized(group.encodedId) + val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() + val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null + val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null + val threadId = storage.getThreadId(group.encodedId) + val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false + val admins = group.admins.associate { it.serialize() to true } + val members = group.members.filterNot { it.serialize() !in admins.keys }.associate { it.serialize() to false } + GroupInfo.LegacyGroupInfo( + accountId = groupPublicKey, + name = group.title, + members = admins + members, + priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = recipient.expireMessages.toLong(), + joinedAt = (group.formationTimestamp / 1000L) + ) + } + .forEach(this::set) +} + +private fun MutableConversationVolatileConfig.initFrom(storage: StorageProtocol, threadDb: ThreadDatabase) { + threadDb.approvedConversationList.use { cursor -> + val reader = threadDb.readerFor(cursor) + var current = reader.next + while (current != null) { + val recipient = current.recipient + val contact = when { + recipient.isCommunityRecipient -> { + val openGroup = storage.getOpenGroup(current.threadId) ?: continue + val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue + getOrConstructCommunity(base, room, pubKey) + } + recipient.isGroupV2Recipient -> { + // 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... + getOrConstructClosedGroup(recipient.address.serialize()) + } + recipient.isLegacyGroupRecipient -> { + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + getOrConstructLegacyGroup(groupPublicKey) + } + recipient.isContactRecipient -> { + if (recipient.isLocalNumber) null // this is handled by the user profile NTS data + else if (recipient.isCommunityInboxRecipient) null // specifically exclude + else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null + else getOrConstructOneToOne(recipient.address.serialize()) + } + else -> null + } + if (contact == null) { + current = reader.next + continue + } + contact.lastRead = current.lastSeen + contact.unread = false + set(contact) + current = reader.next + } + } +} + +private fun MutableUserProfile.initFrom(storage: StorageProtocol) { + val ownPublicKey = storage.getUserPublicKey() ?: return + val config = ConfigurationMessage.getCurrent(listOf()) ?: return + setName(config.displayName) + val picUrl = config.profilePicture + val picKey = config.profileKey + if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { + setPic(UserPic(picUrl, picKey)) + } + val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) + setNtsPriority( + if (ownThreadId != null) + if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + else ConfigBase.PRIORITY_HIDDEN + ) +} + +private fun MutableContacts.initFrom(storage: StorageProtocol) { + val localUserKey = storage.getUserPublicKey() ?: return + val contactsWithSettings = storage.getAllContacts().filter { recipient -> + recipient.accountID != localUserKey && recipient.accountID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.accountID) != null + }.map { contact -> + val address = Address.fromSerialized(contact.accountID) + val thread = storage.getThreadId(address) + val isPinned = if (thread != null) { + storage.isPinned(thread) + } else false + + Triple(contact, storage.getRecipientSettings(address)!!, isPinned) + } + for ((contact, settings, isPinned) in contactsWithSettings) { + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) { + null + } else { + UserPic(url, key) + } + + val contactInfo = Contact( + id = contact.accountID, + name = contact.name.orEmpty(), + nickname = contact.nickname.orEmpty(), + blocked = settings.isBlocked, + approved = settings.isApproved, + approvedMe = settings.hasApprovedMe(), + profilePicture = userPic ?: UserPic.DEFAULT, + priority = if (isPinned) 1 else 0, + expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong()) + ) + set(contactInfo) + } +} + +private class UserConfigsImpl( + userEd25519SecKey: ByteArray, + private val userAccountId: AccountId, + private val configDatabase: ConfigDatabase, + storage: StorageProtocol, + threadDb: ThreadDatabase, + contactsDump: ByteArray? = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.CONTACTS_VARIANT, + userAccountId.hexString + ), + userGroupsDump: ByteArray? = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.USER_GROUPS_VARIANT, + userAccountId.hexString + ), + userProfileDump: ByteArray? = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.USER_PROFILE_VARIANT, + userAccountId.hexString + ), + convoInfoDump: ByteArray? = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.CONVO_INFO_VARIANT, + userAccountId.hexString + ) +) : MutableUserConfigs { + override val contacts = Contacts( + ed25519SecretKey = userEd25519SecKey, + initialDump = contactsDump, + ) + + override val userGroups = UserGroupsConfig( + ed25519SecretKey = userEd25519SecKey, + initialDump = userGroupsDump + ) + override val userProfile = UserProfile( + ed25519SecretKey = userEd25519SecKey, + initialDump = userProfileDump + ) + override val convoInfoVolatile = ConversationVolatileConfig( + ed25519SecretKey = userEd25519SecKey, + initialDump = convoInfoDump, + ) + + init { + if (contactsDump == null) { + contacts.initFrom(storage) + } + + if (userGroupsDump == null) { + userGroups.initFrom(storage) + } + + if (userProfileDump == null) { + userProfile.initFrom(storage) + } + + if (convoInfoDump == null) { + convoInfoVolatile.initFrom(storage, threadDb) + } + } +} + +private class GroupConfigsImpl( + userEd25519SecKey: ByteArray, + private val groupAccountId: AccountId, + groupAdminKey: ByteArray?, + private val configDatabase: ConfigDatabase +) : MutableGroupConfigs { + override val groupInfo = GroupInfoConfig( + groupPubKey = groupAccountId.pubKeyBytes, + groupAdminKey = groupAdminKey, + initialDump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.INFO_VARIANT, + groupAccountId.hexString + ) + ) + override val groupMembers = GroupMembersConfig( + groupPubKey = groupAccountId.pubKeyBytes, + groupAdminKey = groupAdminKey, + initialDump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.MEMBER_VARIANT, + groupAccountId.hexString + ) + ) + override val groupKeys = GroupKeysConfig( + userSecretKey = userEd25519SecKey, + groupPublicKey = groupAccountId.pubKeyBytes, + groupAdminKey = groupAdminKey, + initialDump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.KEYS_VARIANT, + groupAccountId.hexString + ), + info = groupInfo, + members = groupMembers + ) + + fun dumpIfNeeded(clock: SnodeClock): Boolean { + if (groupInfo.needsDump() || groupMembers.needsDump() || groupKeys.needsDump()) { + configDatabase.storeGroupConfigs( + publicKey = groupAccountId.hexString, + keysConfig = groupKeys.dump(), + infoConfig = groupInfo.dump(), + memberConfig = groupMembers.dump(), + timestamp = clock.currentTimeMills() + ) + return true + } + + return false + } + + override fun rekey() { + groupKeys.rekey(groupInfo.pointer, groupMembers.pointer) } } \ 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 00000000000..d0d48b52e68 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.dependencies + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.service.ExpiringMessageManager +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DatabaseBindings { + + @Binds + abstract fun bindStorageProtocol(storage: Storage): StorageProtocol + + @Binds + abstract fun bindLokiAPIDatabaseProtocol(lokiAPIDatabase: LokiAPIDatabase): LokiAPIDatabaseProtocol + + @Binds + abstract fun bindMessageExpirationManagerProtocol(manager: ExpiringMessageManager): SSKEnvironment.MessageExpirationManagerProtocol + +} \ 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 c037f3b27a2..ad5eac883f7 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.MmsSmsDatabase @@ -16,6 +17,7 @@ interface DatabaseComponent { companion object { @JvmStatic + @Deprecated("Use Hilt to inject your dependencies instead") fun get(context: Context) = ApplicationContext.getInstance(context).databaseComponent } 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 30fb40d89a2..179a463fc8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -7,14 +7,36 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider -import org.session.libsession.utilities.SSKEnvironment import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider -import org.thoughtcrime.securesms.database.* +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase +import org.thoughtcrime.securesms.database.ConfigDatabase +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.EmojiSearchDatabase +import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.GroupMemberDatabase +import org.thoughtcrime.securesms.database.GroupReceiptDatabase +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.LokiUserDatabase +import org.thoughtcrime.securesms.database.MediaDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.PushDatabase +import org.thoughtcrime.securesms.database.ReactionDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.SessionJobDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.thoughtcrime.securesms.service.ExpiringMessageManager import javax.inject.Singleton @Module @@ -26,10 +48,6 @@ object DatabaseModule { System.loadLibrary("sqlcipher") } - @Provides - @Singleton - fun provideMessageExpirationManagerProtocol(@ApplicationContext context: Context): SSKEnvironment.MessageExpirationManagerProtocol = ExpiringMessageManager(context) - @Provides @Singleton fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret @@ -139,14 +157,6 @@ object DatabaseModule { @Singleton fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper) - @Provides - @Singleton - fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { - val storage = Storage(context,openHelper, configFactory) - threadDatabase.setUpdateListener(storage) - return storage - } - @Provides @Singleton fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) 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 00000000000..305d2980928 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.dependencies + +import dagger.Lazy +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import network.loki.messenger.libsession_util.util.GroupInfo +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller +import org.session.libsession.snode.SnodeClock +import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.session.libsignal.utilities.AccountId +import java.util.concurrent.ConcurrentHashMap + +class PollerFactory( + private val scope: CoroutineScope, + private val executor: CoroutineDispatcher, + private val configFactory: ConfigFactory, + private val groupManagerV2: Lazy, + private val storage: Lazy, + private val lokiApiDatabase: LokiAPIDatabaseProtocol, + private val clock: SnodeClock, + ) { + + private val pollers = ConcurrentHashMap() + + fun pollerFor(sessionId: AccountId): ClosedGroupPoller? { + // Check if the group is currently in our config and approved, don't start if it isn't + val invited = configFactory.withUserConfigs { + it.userGroups.getClosedGroup(sessionId.hexString)?.invited + } + + if (invited != false) return null + + return pollers.getOrPut(sessionId) { + ClosedGroupPoller( + scope = scope, + executor = executor, + closedGroupSessionId = sessionId, + configFactoryProtocol = configFactory, + groupManagerV2 = groupManagerV2.get(), + storage = storage.get(), + lokiApiDatabase = lokiApiDatabase, + clock = clock, + ) + } + } + + fun startAll() { + configFactory + .withUserConfigs { it.userGroups.allClosedGroupInfo() } + .filterNot(GroupInfo.ClosedGroupInfo::invited) + .forEach { pollerFor(it.groupAccountId)?.start() } + } + + fun stopAll() { + pollers.forEach { (_, poller) -> + poller.stop() + } + } + + fun updatePollers() { + val currentGroups = configFactory + .withUserConfigs { it.userGroups.allClosedGroupInfo() }.filterNot(GroupInfo.ClosedGroupInfo::invited) + val toRemove = pollers.filter { (id, _) -> id !in currentGroups.map { it.groupAccountId } } + 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 cd4b0713382..56e0012da87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -1,36 +1,62 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import org.session.libsession.utilities.ConfigFactoryUpdateListener -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.thoughtcrime.securesms.database.ConfigDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import javax.inject.Named import javax.inject.Singleton +@Suppress("OPT_IN_USAGE") @Module @InstallIn(SingletonComponent::class) object SessionUtilModule { - private fun maybeUserEdSecretKey(context: Context): ByteArray? { - val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null - return edKey.secretKey.asBytes - } + private const val POLLER_SCOPE = "poller_coroutine_scope" + + @Provides + @Named(POLLER_SCOPE) + fun providePollerScope(): CoroutineScope = GlobalScope + + @OptIn(ExperimentalCoroutinesApi::class) + @Provides + @Named(POLLER_SCOPE) + fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) @Provides @Singleton - fun provideConfigFactory(@ApplicationContext context: Context, configDatabase: ConfigDatabase): ConfigFactory = - ConfigFactory(context, configDatabase) { - val localUserPublicKey = TextSecurePreferences.getLocalNumber(context) - val secretKey = maybeUserEdSecretKey(context) - if (localUserPublicKey == null || secretKey == null) null - else secretKey to localUserPublicKey - }.apply { - registerListener(context as ConfigFactoryUpdateListener) - } + fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope, + @Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher, + configFactory: ConfigFactory, + storage: Lazy, + groupManagerV2: Lazy, + lokiApiDatabase: LokiAPIDatabaseProtocol, + clock: SnodeClock) = PollerFactory( + scope = coroutineScope, + executor = dispatcher, + configFactory = configFactory, + groupManagerV2 = groupManagerV2, + storage = storage, + lokiApiDatabase = lokiApiDatabase, + clock = clock, + ) + @Provides + @Singleton + fun provideSnodeClock() = SnodeClock() } \ 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 adeeeb91fa1..cfc0e87c65b 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 @@ -25,7 +25,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) { @@ -33,30 +33,26 @@ 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(group: GroupRecord) { - val groups = userGroups ?: return - if (!group.isClosedGroup) return + if (!group.isLegacyGroup) return val storage = MessagingModuleConfiguration.shared.storage val threadId = storage.getThreadId(group.encodedId) ?: return val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) val latestKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return - val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey) - val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize)) - val toSet = legacyInfo.copy( - members = latestMemberMap, - name = group.title, - priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, - encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte - encSecKey = latestKeyPair.privateKey.serialize() - ) - groups.set(toSet) - } + withMutableUserConfigs { + val groups = it.userGroups + + val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey) + val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize)) + val toSet = legacyInfo.copy( + members = latestMemberMap, + name = group.title, + priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = latestKeyPair.privateKey.serialize() + ) + groups.set(toSet) + } + } } \ No newline at end of file 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 0f562c80b7d..e3b656dc5e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -1,127 +1,43 @@ package org.thoughtcrime.securesms.groups -import android.content.Context import android.content.Intent import android.os.Bundle 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.ui.platform.ComposeView import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -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.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut -import javax.inject.Inject +import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme -@AndroidEntryPoint class CreateGroupFragment : Fragment() { - - @Inject - lateinit var device: Device - - private lateinit var binding: FragmentCreateGroupBinding - private val viewModel: CreateGroupViewModel by viewModels() - - lateinit var delegate: StartConversationDelegate - override fun onCreateView( 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(), Glide.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() } - } - } - 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) + return ComposeView(requireContext()).apply { + val delegate = (parentFragment as? StartConversationDelegate) + ?: (activity as? StartConversationDelegate) + ?: NullStartConversationDelegate + + setContent { + SessionMaterialTheme { + CreateGroupScreen( + onNavigateToConversationScreen = { threadID -> + startActivity( + Intent(requireContext(), ConversationActivityV2::class.java) + .putExtra(ConversationActivityV2.THREAD_ID, threadID) + ) + }, + onBack = delegate::onDialogBackPressed, + onClose = delegate::onDialogClosePressed + ) + } } } - 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.groupNameEnterPlease, Toast.LENGTH_LONG).show() - } - - // Limit the group name length if it exceeds the limit - if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) { - return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterShorter, Toast.LENGTH_LONG).show() - } - - val selectedMembers = adapter.selectedMembers - if (selectedMembers.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.groupCreateErrorNoMembers, 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.groupAddMemberMaximum, 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) } +} -} \ 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 b3dbb49384a..5a20d7be9ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -1,46 +1,109 @@ package org.thoughtcrime.securesms.groups -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow 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 org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject + @HiltViewModel class CreateGroupViewModel @Inject constructor( - private val threadDb: ThreadDatabase, - private val textSecurePreferences: TextSecurePreferences -) : ViewModel() { + configFactory: ConfigFactory, + @ApplicationContext appContext: Context, + private val storage: StorageProtocol, + private val groupManagerV2: GroupManagerV2, +): ViewModel() { + // Child view model to handle contact selection logic + val selectContactsViewModel = SelectContactsViewModel( + storage = storage, + configFactory = configFactory, + excludingAccountIDs = emptySet(), + scope = viewModelScope, + appContext = appContext, + ) + + // Input: group name + private val mutableGroupName = MutableStateFlow("") + private val mutableGroupNameError = MutableStateFlow("") + + // Output: group name + val groupName: StateFlow get() = mutableGroupName + val groupNameError: StateFlow get() = mutableGroupNameError - private val _recipients = MutableLiveData>() - val recipients: LiveData> = _recipients + // Output: loading state + private val mutableIsLoading = MutableStateFlow(false) + val isLoading: StateFlow get() = mutableIsLoading - init { + // Events + private val mutableEvents = MutableSharedFlow() + val events: SharedFlow get() = mutableEvents + + fun onCreateClicked() { viewModelScope.launch { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val recipients = mutableListOf() - while (true) { - recipients += reader.next?.recipient ?: break + val groupName = groupName.value.trim() + if (groupName.isBlank()) { + mutableGroupNameError.value = "Group name cannot be empty" + return@launch + } + + val selected = selectContactsViewModel.currentSelected + if (selected.isEmpty()) { + mutableEvents.emit(CreateGroupEvent.Error("Please select at least one contact")) + return@launch + } + + mutableIsLoading.value = true + + val createResult = withContext(Dispatchers.Default) { + runCatching { + groupManagerV2.createGroup( + groupName = groupName, + groupDescription = "", + members = selected + ) } - withContext(Dispatchers.Main) { - _recipients.value = recipients - .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } + } + + when (val recipient = createResult.getOrNull()) { + null -> { + mutableEvents.emit(CreateGroupEvent.Error("Failed to create group")) + + } + else -> { + val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.address) } + mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId)) } } + + mutableIsLoading.value = false } } - fun filter(query: String): List { - return _recipients.value?.filter { - it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true - } ?: emptyList() + fun onGroupNameChanged(name: String) { + mutableGroupName.value = if (name.length > MAX_GROUP_NAME_LENGTH) { + name.substring(0, MAX_GROUP_NAME_LENGTH) + } else { + name + } + + mutableGroupNameError.value = "" } +} + +sealed interface CreateGroupEvent { + data class NavigateToConversation(val threadID: Long): CreateGroupEvent + + data class Error(val message: String): CreateGroupEvent } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt new file mode 100644 index 00000000000..c8750c70727 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import dagger.hilt.android.AndroidEntryPoint +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.groups.compose.EditGroupScreen +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme + +@AndroidEntryPoint +class EditGroupActivity: PassphraseRequiredActionBarActivity() { + + companion object { + private const val EXTRA_GROUP_ID = "EditClosedGroupActivity_groupID" + + fun createIntent(context: Context, groupSessionId: String): Intent { + return Intent(context, EditGroupActivity::class.java).apply { + putExtra(EXTRA_GROUP_ID, groupSessionId) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + setContent { + SessionMaterialTheme { + EditGroupScreen( + groupId = AccountId(intent.getStringExtra(EXTRA_GROUP_ID)!!), + onFinish = this::finish + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt new file mode 100644 index 00000000000..b40bff5c87a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -0,0 +1,343 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.GroupDisplayInfo +import network.loki.messenger.libsession_util.util.GroupMember +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.dependencies.ConfigFactory + +const val MAX_GROUP_NAME_LENGTH = 100 + +@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class) +class EditGroupViewModel @AssistedInject constructor( + @Assisted private val groupId: AccountId, + @ApplicationContext private val context: Context, + private val storage: StorageProtocol, + configFactory: ConfigFactory, + private val groupManager: GroupManagerV2, +) : ViewModel() { + // Input/Output state + private val mutableEditingName = MutableStateFlow(null) + + // Input/Output: the name that has been written and submitted for change to push to the server, + // but not yet confirmed by the server. When this state is present, it takes precedence over + // the group name in the group info. + private val mutablePendingEditedName = MutableStateFlow(null) + + // Input: invite/promote member's intermediate states. This is needed because we don't have + // a state that we can map into in the config system. The config system only provides "sent", "failed", etc. + // The intermediate states are needed to show the user that the operation is in progress, and the + // states are limited to the view model (i.e. lost if the user navigates away). This is a trade-off + // between the complexity of the config system and the user experience. + private val memberPendingState = MutableStateFlow>(emptyMap()) + + // Output: The name of the group being edited. Null if it's not in edit mode, not to be confused + // with empty string, where it's a valid editing state. + val editingName: StateFlow get() = mutableEditingName + + // Output: the source-of-truth group information. Other states are derived from this. + private val groupInfo: StateFlow>?> = + combine( + configFactory.configUpdateNotifications + .filterIsInstance() + .filter { it.groupId == groupId } + .onStart { emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId)) }, + memberPendingState + ) { _, pending -> + withContext(Dispatchers.Default) { + val currentUserId = AccountId(checkNotNull(storage.getUserPublicKey()) { + "User public key is null" + }) + + val displayInfo = storage.getClosedGroupDisplayInfo(groupId.hexString) + ?: return@withContext null + + val members = storage.getMembers(groupId.hexString) + .filterTo(mutableListOf()) { !it.removed } + sortMembers(members, currentUserId) + + displayInfo to members.map { member -> + createGroupMember( + member = member, + myAccountId = currentUserId, + amIAdmin = displayInfo.isUserAdmin, + pendingState = pending[AccountId(member.sessionId)] + ) + } + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + // Output: whether the group name can be edited. This is true if the group is loaded successfully. + val canEditGroupName: StateFlow = groupInfo + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + // Output: The name of the group. This is the current name of the group, not the name being edited. + val groupName: StateFlow = combine(groupInfo + .map { it?.first?.name.orEmpty() }, mutablePendingEditedName) { name, pendingName -> pendingName ?: name } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + + // Output: the list of the members and their state in the group. + val members: StateFlow> = groupInfo + .map { it?.second.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + // Output: whether we should show the "add members" button + val showAddMembers: StateFlow = groupInfo + .map { it?.first?.isUserAdmin == true } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + // Output: Intermediate states + private val mutableInProgress = MutableStateFlow(false) + val inProgress: StateFlow get() = mutableInProgress + + // Output: errors + private val mutableError = MutableStateFlow(null) + val error: StateFlow get() = mutableError + + // Output: + val excludingAccountIDsFromContactSelection: Set + get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId }.orEmpty() + + private fun createGroupMember( + member: GroupMember, + myAccountId: AccountId, + amIAdmin: Boolean, + pendingState: MemberPendingState? + ): GroupMemberState { + var status = "" + var highlightStatus = false + var name = member.name.orEmpty().ifEmpty { member.sessionId } + + when { + member.sessionId == myAccountId.hexString -> { + name = context.getString(R.string.you) + } + + pendingState == MemberPendingState.Inviting -> { + status = context.getString(R.string.groupInviteSending) + } + + pendingState == MemberPendingState.Promoting -> { + status = context.getString(R.string.groupInviteSending) + } + + member.promotionPending -> { + status = context.getString(R.string.adminPromotionSent) + } + + member.invitePending -> { + status = context.getString(R.string.groupInviteSent) + } + + member.inviteFailed -> { + status = context.getString(R.string.groupInviteFailed) + highlightStatus = true + } + + member.promotionFailed -> { + status = context.getString(R.string.adminPromotionFailed) + highlightStatus = true + } + } + + return GroupMemberState( + accountId = AccountId(member.sessionId), + name = name, + canRemove = amIAdmin && member.sessionId != myAccountId.hexString && !member.isAdminOrBeingPromoted, + canPromote = amIAdmin && member.sessionId != myAccountId.hexString && !member.isAdminOrBeingPromoted, + canResendPromotion = amIAdmin && member.sessionId != myAccountId.hexString && member.promotionFailed, + canResendInvite = amIAdmin && member.sessionId != myAccountId.hexString && + (member.inviteFailed || member.invitePending), + status = status, + highlightStatus = highlightStatus + ) + } + + private fun sortMembers(members: MutableList, currentUserId: AccountId) { + members.sortWith( + compareBy( + { !it.inviteFailed }, // Failed invite comes first (as false value is less than true) + { memberPendingState.value[AccountId(it.sessionId)] != MemberPendingState.Inviting }, // "Sending invite" comes first + { !it.invitePending }, // "Invite sent" comes first + { !it.isAdminOrBeingPromoted }, // Admins come first + { it.sessionId != currentUserId.hexString }, // Being myself comes first + { it.name }, // Sort by name + { it.sessionId } // Last resort: sort by account ID + ) + ) + } + + fun onContactSelected(contacts: Set) { + performGroupOperation { + try { + // Mark the contacts as pending + memberPendingState.update { states -> + states + contacts.associateWith { MemberPendingState.Inviting } + } + + groupManager.inviteMembers( + groupId, + contacts.toList(), + shareHistory = false + ) + } finally { + // Remove pending state (so the real state will be revealed) + memberPendingState.update { states -> states - contacts } + } + } + } + + fun onResendInviteClicked(contactSessionId: AccountId) { + onContactSelected(setOf(contactSessionId)) + } + + fun onPromoteContact(memberSessionId: AccountId) { + performGroupOperation { + try { + memberPendingState.update { states -> + states + (memberSessionId to MemberPendingState.Promoting) + } + + groupManager.promoteMember(groupId, listOf(memberSessionId)) + } finally { + memberPendingState.update { states -> states - memberSessionId } + } + } + } + + fun onRemoveContact(contactSessionId: AccountId, removeMessages: Boolean) { + performGroupOperation { + groupManager.removeMembers( + groupAccountId = groupId, + removedMembers = listOf(contactSessionId), + removeMessages = removeMessages + ) + } + } + + fun onResendPromotionClicked(memberSessionId: AccountId) { + onPromoteContact(memberSessionId) + } + + fun onEditNameClicked() { + mutableEditingName.value = groupInfo.value?.first?.name.orEmpty() + } + + fun onCancelEditingNameClicked() { + mutableEditingName.value = null + } + + fun onEditingNameChanged(value: String) { + // Cut off the group name so we don't exceed max length + if (value.length > MAX_GROUP_NAME_LENGTH) { + mutableEditingName.value = value.substring(0, MAX_GROUP_NAME_LENGTH) + } else { + mutableEditingName.value = value + } + } + + fun onEditNameConfirmClicked() { + val newName = mutableEditingName.value + if (newName.isNullOrBlank()) { + return + } + + // Move the edited name into the pending state + mutableEditingName.value = null + mutablePendingEditedName.value = newName + + performGroupOperation { + try { + groupManager.setName(groupId, newName) + } finally { + // As soon as the operation is done, clear the pending state, + // no matter if it's successful or not. So that we update the UI to reflect the + // real state. + mutablePendingEditedName.value = null + } + } + } + + fun onDismissError() { + mutableError.value = null + } + + /** + * Perform a group operation, such as inviting a member, removing a member. + * + * This is a helper function that encapsulates the common error handling and progress tracking. + */ + private fun performGroupOperation( + genericErrorMessage: (() -> String?)? = null, + operation: suspend () -> Unit) { + viewModelScope.launch { + mutableInProgress.value = true + + // We need to use GlobalScope here because we don't want + // any group operation to be cancelled when the view model is cleared. + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + } catch (e: Exception) { + mutableError.value = genericErrorMessage?.invoke() + ?: context.getString(R.string.errorUnknown) + } finally { + mutableInProgress.value = false + } + } + } + + @AssistedFactory + interface Factory { + fun create(groupId: AccountId): EditGroupViewModel + } +} + +private enum class MemberPendingState { + Inviting, + Promoting, +} + +data class GroupMemberState( + val accountId: AccountId, + val name: String, + val status: String, + val highlightStatus: Boolean, + val canResendInvite: Boolean, + val canResendPromotion: Boolean, + val canRemove: Boolean, + val canPromote: Boolean, +) { + val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt similarity index 71% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt index b1e0b5e1d82..3c34395c8bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.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 EditLegacyClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) { - override fun loadInBackground(): EditClosedGroupActivity.GroupMembers { + override fun loadInBackground(): EditLegacyGroupActivity.GroupMembers { val groupDatabase = DatabaseComponent.get(context).groupDatabase() val members = groupDatabase.getGroupMembers(groupID, true) val zombieMembers = groupDatabase.getGroupZombieMembers(groupID) - return EditClosedGroupActivity.GroupMembers( + return EditLegacyGroupActivity.GroupMembers( members.map { it.address.toString() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt similarity index 91% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt index 11dde4b93ed..f76673feaa8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt @@ -18,15 +18,12 @@ import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import java.io.IOException import javax.inject.Inject 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 @@ -43,12 +40,11 @@ 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 com.bumptech.glide.Glide import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut @AndroidEntryPoint -class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { +class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var groupConfigFactory: ConfigFactory @@ -80,9 +76,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { private val memberListAdapter by lazy { if (isSelfAdmin) - EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick) + EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick) else - EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin) + EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin) } private lateinit var mainContentContainer: LinearLayout @@ -129,7 +125,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { findViewById(R.id.rvUserList).apply { adapter = memberListAdapter - layoutManager = LinearLayoutManager(this@EditClosedGroupActivity) + layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity) } lblGroupNameDisplay.text = originalName @@ -162,13 +158,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID) + return EditLegacyClosedGroupLoader(this@EditLegacyGroupActivity, 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) + LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID) members.clear() members.addAll(groupMembers.members.toHashSet()) @@ -192,7 +188,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { // endregion // region Updating - @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { @@ -252,7 +247,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } private fun onAddMembersClick() { - val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java) + val intent = Intent(this@EditLegacyGroupActivity, SelectContactsActivity::class.java) intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") startActivityForResult(intent, addUsersRequestCode) @@ -320,10 +315,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { 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 { + try { + if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { + MessageSender.explicitLeave(groupPublicKey!!, false) + } else { if (hasNameChanged) { MessageSender.explicitNameChange(groupPublicKey!!, name) } @@ -334,15 +329,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) } } - } - promise.successUi { loaderContainer.fadeOut() isLoading = false updateGroupConfig() finish() - }.failUi { exception -> + } catch (exception: Exception) { val message = if (exception is MessageSender.Error) exception.description else "An error occurred" - Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + Toast.makeText(this@EditLegacyGroupActivity, message, Toast.LENGTH_LONG).show() loaderContainer.fadeOut() isLoading = false } @@ -350,8 +343,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } 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(latestGroup) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt index 5127e3be724..248b858376a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt @@ -9,12 +9,12 @@ import com.bumptech.glide.RequestManager import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.TextSecurePreferences -class EditClosedGroupMembersAdapter( +class EditLegacyGroupMembersAdapter( private val context: Context, private val glide: RequestManager, private val admin: Boolean, private val memberClickListener: ((String) -> Unit)? = null -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { private val members = ArrayList() private val zombieMembers = ArrayList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt new file mode 100644 index 00000000000..2c94f4243c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -0,0 +1,1028 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import com.google.protobuf.ByteString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.GroupMember +import network.loki.messenger.libsession_util.util.INVITE_STATUS_FAILED +import network.loki.messenger.libsession_util.util.INVITE_STATUS_SENT +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.jobs.InviteContactsJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.visible.Profile +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.model.BatchResponse +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.getGroup +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.waitUntilGroupConfigsPushed +import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.protos.SignalServiceProtos.DataMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage +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.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.PollerFactory +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "GroupManagerV2Impl" + +@Singleton +class GroupManagerV2Impl @Inject constructor( + private val storage: StorageProtocol, + private val configFactory: ConfigFactory, + private val mmsSmsDatabase: MmsSmsDatabase, + private val lokiDatabase: LokiMessageDatabase, + private val pollerFactory: PollerFactory, + private val profileManager: SSKEnvironment.ProfileManagerProtocol, + @ApplicationContext val application: Context, + private val clock: SnodeClock, + private val messageDataProvider: MessageDataProvider, +) : GroupManagerV2 { + private val dispatcher = Dispatchers.Default + + /** + * Require admin access to a group, and return the admin key. + * + * @throws IllegalArgumentException if the group does not exist or no admin key is found. + */ + private fun requireAdminAccess(group: AccountId): ByteArray { + return checkNotNull( + configFactory.getGroup(group) + ?.adminKey + ?.takeIf { it.isNotEmpty() } + ) { "Only admin is allowed to invite members" } + } + + override suspend fun createGroup( + groupName: String, + groupDescription: String, + members: Set + ): Recipient = withContext(dispatcher) { + val ourAccountId = + requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" } + val ourProfile = storage.getUserProfile() + + val groupCreationTimestamp = clock.currentTimeMills() + + // Create a group in the user groups config + val group = configFactory.withMutableUserConfigs { configs -> + configs.userGroups.createGroup() + .copy(name = groupName) + .also(configs.userGroups::set) + } + + val adminKey = checkNotNull(group.adminKey) { "Admin key is null for new group creation." } + val groupId = group.groupAccountId + + val memberAsRecipients = members.map { + Recipient.from(application, Address.fromSerialized(it.hexString), false) + } + + try { + configFactory.withMutableGroupConfigs(groupId) { configs -> + // Update group's information + configs.groupInfo.setName(groupName) + configs.groupInfo.setDescription(groupDescription) + + // Add members + for (member in memberAsRecipients) { + configs.groupMembers.set( + GroupMember( + sessionId = member.address.serialize(), + name = member.name, + profilePicture = member.profileAvatar?.let { url -> + member.profileKey?.let { key -> UserPic(url, key) } + } ?: UserPic.DEFAULT, + inviteStatus = INVITE_STATUS_SENT + ) + ) + } + + // Add ourselves as admin + configs.groupMembers.set( + GroupMember( + sessionId = ourAccountId, + name = ourProfile.displayName, + profilePicture = ourProfile.profilePicture ?: UserPic.DEFAULT, + admin = true + ) + ) + + // Manually re-key to prevent issue with linked admin devices + configs.rekey() + } + + if (!configFactory.waitUntilGroupConfigsPushed(groupId)) { + Log.w(TAG, "Unable to push group configs in a timely manner") + } + + configFactory.withMutableUserConfigs { + it.convoInfoVolatile.set( + Conversation.ClosedGroup( + groupId.hexString, + groupCreationTimestamp, + false + ) + ) + } + + val recipient = + Recipient.from(application, Address.fromSerialized(groupId.hexString), false) + + // Apply various data locally + profileManager.setName(application, recipient, groupName) + storage.setRecipientApprovedMe(recipient, true) + storage.setRecipientApproved(recipient, true) + pollerFactory.updatePollers() + + // Invite members + JobQueue.shared.add( + InviteContactsJob( + groupSessionId = groupId.hexString, + memberSessionIds = members.map { it.hexString }.toTypedArray() + ) + ) + + // Also send a group update message + sendGroupUpdateForAddingMembers(groupId, adminKey, members, insertLocally = false) + + recipient + } catch (e: Exception) { + Log.e(TAG, "Failed to create group", e) + + // Remove the group from the user groups config is sufficient as a "rollback" + configFactory.withMutableUserConfigs { + it.userGroups.eraseClosedGroup(groupId.hexString) + } + throw e + } + } + + + override suspend fun inviteMembers( + group: AccountId, + newMembers: List, + shareHistory: Boolean + ): Unit = withContext(dispatcher) { + val adminKey = requireAdminAccess(group) + val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) + + val batchRequests = mutableListOf() + + // Construct the new members in our config + val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> + // Construct the new members in the config + for (newMember in newMembers) { + val toSet = configs.groupMembers.get(newMember.hexString) + ?.let { existing -> + if (existing.inviteFailed || existing.invitePending) { + existing.copy( + inviteStatus = INVITE_STATUS_SENT, + supplement = shareHistory + ) + } else { + existing + } + } + ?: configs.groupMembers.getOrConstruct(newMember.hexString).let { member -> + val contact = configFactory.withUserConfigs { configs -> + configs.contacts.get(newMember.hexString) + } + + member.copy( + name = contact?.name, + profilePicture = contact?.profilePicture ?: UserPic.DEFAULT, + inviteStatus = INVITE_STATUS_SENT, + supplement = shareHistory + ) + } + + configs.groupMembers.set(toSet) + } + + // Depends on whether we want to share history, we may need to rekey or just adding rsupplement keys + if (shareHistory) { + val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) + batchRequests.add( + SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.ENCRYPTION_KEYS(), + message = SnodeMessage( + recipient = group.hexString, + data = Base64.encodeBytes(memberKey), + ttl = SnodeMessage.CONFIG_TTL, + timestamp = clock.currentTimeMills(), + ), + auth = groupAuth, + ) + ) + } else { + configs.rekey() + } + + newMembers.map { configs.groupKeys.getSubAccountToken(it) } + } + + + // Call un-revocate API on new members, in case they have been removed before + batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = subAccountTokens + ) + + // Call the API + val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() + val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) + + // Make sure every request is successful + response.requireAllRequestsSuccessful("Failed to invite members") + + // Send the invitation message to the new members + JobQueue.shared.add( + InviteContactsJob( + group.hexString, + newMembers.map { it.hexString }.toTypedArray() + ) + ) + + // Send a group update message to the group telling members someone has been invited + sendGroupUpdateForAddingMembers(group, adminKey, newMembers, insertLocally = true) + } + + /** + * Send a group update message to the group telling members someone has been invited. + */ + private fun sendGroupUpdateForAddingMembers( + group: AccountId, + adminKey: ByteArray, + newMembers: Collection, + insertLocally: Boolean + ) { + val timestamp = clock.currentTimeMills() + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), + adminKey + ) + + val updatedMessage = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(newMembers.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.ADDED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { this.sentTimestamp = timestamp } + MessageSender.send(updatedMessage, Destination.ClosedGroup(group.hexString), false) + + if (insertLocally) { + storage.insertGroupInfoChange(updatedMessage, group) + } + } + + override suspend fun removeMembers( + groupAccountId: AccountId, + removedMembers: List, + removeMessages: Boolean + ) { + val adminKey = requireAdminAccess(groupAccountId) + + // Update the config to mark this member as "removed" + flagMembersForRemoval( + group = groupAccountId, + groupAdminKey = adminKey, + members = removedMembers, + alsoRemoveMembersMessage = removeMessages, + ) + + val timestamp = clock.currentTimeMills() + val signature = SodiumUtilities.sign( + buildMemberChangeSignature( + GroupUpdateMemberChangeMessage.Type.REMOVED, + timestamp + ), + adminKey + ) + + val updateMessage = GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + val message = GroupUpdated( + updateMessage + ).apply { sentTimestamp = timestamp } + + MessageSender.send(message, Destination.ClosedGroup(groupAccountId.hexString), false).await() + storage.insertGroupInfoChange(message, groupAccountId) + } + + override suspend fun removeMemberMessages( + groupAccountId: AccountId, + members: List + ): Unit = withContext(dispatcher) { + val messagesToDelete = mutableListOf() + + val threadId = storage.getThreadId(Address.fromSerialized(groupAccountId.hexString)) + if (threadId != null) { + for (member in members) { + for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) { + val serverHash = lokiDatabase.getMessageServerHash(msg.id, msg.isMms) + if (serverHash != null) { + messagesToDelete.add(serverHash) + } + } + + storage.deleteMessagesByUser(threadId, member.hexString) + } + } + + if (messagesToDelete.isEmpty()) { + return@withContext + } + + val groupAdminAuth = configFactory.getGroup(groupAccountId)?.adminKey?.let { + OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) + } ?: return@withContext + + SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) + } + + override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) { + val closedGroup = configFactory.getGroup(group) ?: return + val groupAdminKey = closedGroup.adminKey + + if (groupAdminKey != null) { + flagMembersForRemoval( + group = group, + groupAdminKey = groupAdminKey, + members = listOf(memberId), + alsoRemoveMembersMessage = false, + ) + } + } + + override suspend fun leaveGroup(groupId: AccountId, deleteOnLeave: Boolean) = withContext(dispatcher + SupervisorJob()) { + val group = configFactory.getGroup(groupId) + + // Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment) + val weAreTheOnlyAdmin = configFactory.withGroupConfigs(groupId) { config -> + val allMembers = config.groupMembers.all() + allMembers.count { it.admin } == 1 && + allMembers.first { it.admin }.sessionId == storage.getUserPublicKey() + } + + if (group?.kicked == false) { + val destination = Destination.ClosedGroup(groupId.hexString) + val sendMessageTasks = mutableListOf>() + + // Always send a "XXX left" message to the group if we can + sendMessageTasks += async { + MessageSender.send( + GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) + .build() + ), + destination, + isSyncMessage = false + ).await() + } + + + // If we are not the only admin, send a left message for other admin to handle the member removal + if (!weAreTheOnlyAdmin) { + sendMessageTasks += async { + MessageSender.send( + GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .build() + ), + destination, + isSyncMessage = false + ).await() + } + } + + sendMessageTasks.awaitAll() + } + + // If we are the only admin, leaving this group will destroy the group + if (weAreTheOnlyAdmin) { + configFactory.withMutableGroupConfigs(groupId) { configs -> + configs.groupInfo.destroyGroup() + } + + // Must wait until the config is pushed, otherwise if we go through the rest + // of the code it will destroy the conversation, destroying the necessary configs + // along the way, we won't be able to push the "destroyed" state anymore. + configFactory.waitUntilGroupConfigsPushed(groupId) + } + + pollerFactory.pollerFor(groupId)?.stop() + + if (deleteOnLeave) { + storage.getThreadId(Address.fromSerialized(groupId.hexString)) + ?.let(storage::deleteConversation) + configFactory.removeGroup(groupId) + } + } + + override suspend fun promoteMember( + group: AccountId, + members: List + ): Unit = withContext(dispatcher + SupervisorJob()) { + val adminKey = requireAdminAccess(group) + val groupName = configFactory.withGroupConfigs(group) { it.groupInfo.getName() } + + // Send out the promote message to the members concurrently + val promoteMessage = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setPromoteMessage( + DataMessage.GroupUpdatePromoteMessage.newBuilder() + .setGroupIdentitySeed(ByteString.copyFrom(adminKey)) + .setName(groupName) + ) + .build() + ) + + val promotionDeferred = members.associateWith { member -> + async { + MessageSender.sendNonDurably( + message = promoteMessage, + address = Address.fromSerialized(member.hexString), + isSyncMessage = false + ).await() + } + } + + // Wait and gather all the promote message sending result into a result map + val promotedByMemberIDs = promotionDeferred + .mapValues { + runCatching { it.value.await() }.isSuccess + } + + // Update each member's status + configFactory.withMutableGroupConfigs(group) { configs -> + promotedByMemberIDs.asSequence() + .mapNotNull { (member, success) -> + configs.groupMembers.get(member.hexString)?.copy( + promotionStatus = if (success) { + INVITE_STATUS_SENT + } else { + INVITE_STATUS_FAILED + } + ) + } + .forEach(configs.groupMembers::set) + } + + // Send a group update message to the group telling members someone has been promoted + val timestamp = clock.currentTimeMills() + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), + adminKey + ) + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(members.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { + sentTimestamp = timestamp + } + + MessageSender.send(message, Destination.ClosedGroup(group.hexString), false).await() + storage.insertGroupInfoChange(message, group) + } + + /** + * Mark this member as "removed" in the group config. + * + * [RemoveGroupMemberHandler] should be able to pick up the config changes and remove the member from the group. + */ + private fun flagMembersForRemoval( + group: AccountId, + groupAdminKey: ByteArray, // Not used ATM required here for verification purpose + members: List, + alsoRemoveMembersMessage: Boolean, + ) { + configFactory.withMutableGroupConfigs(group) { configs -> + for (member in members) { + val memberConfig = configs.groupMembers.get(member.hexString) + if (memberConfig != null) { + configs.groupMembers.set(memberConfig.setRemoved(alsoRemoveMembersMessage)) + } + } + } + } + + override suspend fun respondToInvitation(groupId: AccountId, approved: Boolean) = + withContext(dispatcher) { + val group = requireNotNull( + configFactory.withUserConfigs { it.userGroups.getClosedGroup(groupId.hexString) } + ) { "User groups config is not available" } + + val threadId = + checkNotNull(storage.getThreadId(Address.fromSerialized(groupId.hexString))) { + "No thread has been created for the group" + } + + // Whether approved or not, delete the invite + lokiDatabase.deleteGroupInviteReferrer(threadId) + + if (approved) { + approveGroupInvite(group) + } else { + configFactory.withMutableUserConfigs { it.userGroups.eraseClosedGroup(groupId.hexString) } + storage.deleteConversation(threadId) + } + } + + private suspend fun approveGroupInvite( + group: GroupInfo.ClosedGroupInfo, + ) { + val key = requireNotNull(storage.getUserPublicKey()) { + "Our account ID is not available" + } + + // Clear the invited flag of the group in the config + configFactory.withMutableUserConfigs { configs -> + configs.userGroups.set(group.copy(invited = false)) + } + + val poller = checkNotNull(pollerFactory.pollerFor(group.groupAccountId)) { "Unable to start a poller for groups " } + poller.start() + + // We need to wait until we have the first data polled from the poller, otherwise + // we won't have the necessary configs to send invite response/or do anything else + poller.state.filterIsInstance() + .filter { it.hadAtLeastOneSuccessfulPoll } + .first() + + if (group.adminKey == null) { + // Send an invite response to the group if we are invited as a regular member + 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, + Destination.ClosedGroup(group.groupAccountId.hexString), + isSyncMessage = false + ) + } else { + // If we are invited as admin, we can just update the group info ourselves + configFactory.withMutableGroupConfigs(group.groupAccountId) { configs -> + configs.groupMembers.get(key)?.let { member -> + configs.groupMembers.set(member.setPromoteSuccess().setAccepted()) + } + + Unit + } + } + } + + override suspend fun handleInvitation( + groupId: AccountId, + groupName: String, + authData: ByteArray, + inviter: AccountId, + inviteMessageHash: String, + inviteMessageTimestamp: Long, + ): Unit = withContext(dispatcher) { + handleInvitation( + groupId = groupId, + groupName = groupName, + authDataOrAdminKey = authData, + fromPromotion = false, + inviter = inviter, + inviteMessageTimestamp = inviteMessageTimestamp, + ) + + // Once we are done, delete the invite message remotely + val auth = requireNotNull(storage.userAuth) { "No current user available" } + SnodeAPI.deleteMessage(groupId.hexString, auth, listOf(inviteMessageHash)) + } + + override suspend fun handlePromotion( + groupId: AccountId, + groupName: String, + adminKey: ByteArray, + promoter: AccountId, + promoteMessageHash: String, + promoteMessageTimestamp: Long, + ): Unit = withContext(dispatcher) { + val userAuth = requireNotNull(storage.userAuth) { "No current user available" } + val group = configFactory.getGroup(groupId) + + if (group == null) { + // If we haven't got the group in the config, it could mean that we haven't + // processed the invitation, or the invitation message is lost. We'll need to + // go through the invitation process again. + handleInvitation( + groupId = groupId, + groupName = groupName, + authDataOrAdminKey = adminKey, + fromPromotion = true, + inviter = promoter, + inviteMessageTimestamp = promoteMessageTimestamp, + ) + } else { + // If we have the group in the config, we can just update the admin key + configFactory.withMutableUserConfigs { + it.userGroups.set(group.copy(adminKey = adminKey)) + } + + // Update our promote state + configFactory.withMutableGroupConfigs( + recreateConfigInstances = true, + groupId = groupId + ) { configs -> + configs.groupMembers.get(userAuth.accountId.hexString)?.let { member -> + configs.groupMembers.set(member.setPromoteSuccess()) + } + } + } + + // Delete the promotion message remotely + SnodeAPI.deleteMessage( + userAuth.accountId.hexString, + userAuth, + listOf(promoteMessageHash) + ) + } + + /** + * Handle an invitation to a group. + * + * @param groupId the group ID + * @param groupName the group name + * @param authDataOrAdminKey the auth data or admin key. If this is an invitation, this is the auth data, if this is a promotion, this is the admin key. + * @param fromPromotion true if this is a promotion, false if this is an invitation + * @param inviter the invite message sender + * @return The newly created group info if the invitation is processed, null otherwise. + */ + private suspend fun handleInvitation( + groupId: AccountId, + groupName: String, + authDataOrAdminKey: ByteArray, + fromPromotion: Boolean, + inviter: AccountId, + inviteMessageTimestamp: Long + ) { + // If we have already received an invitation in the past, we should not process this one + if (configFactory.getGroup(groupId)?.invited == true) { + return + } + + val recipient = + Recipient.from(application, Address.fromSerialized(groupId.hexString), false) + + val shouldAutoApprove = + storage.getRecipientApproved(Address.fromSerialized(inviter.hexString)) + val closedGroupInfo = GroupInfo.ClosedGroupInfo( + groupAccountId = groupId, + adminKey = authDataOrAdminKey.takeIf { fromPromotion }, + authData = authDataOrAdminKey.takeIf { !fromPromotion }, + priority = PRIORITY_VISIBLE, + invited = !shouldAutoApprove, + name = groupName, + ) + + configFactory.withMutableUserConfigs { + it.userGroups.set(closedGroupInfo) + } + + profileManager.setName(application, recipient, groupName) + val groupThreadId = storage.getOrCreateThreadIdFor(recipient.address) + storage.setRecipientApprovedMe(recipient, true) + storage.setRecipientApproved(recipient, shouldAutoApprove) + + if (shouldAutoApprove) { + approveGroupInvite(closedGroupInfo) + } else { + lokiDatabase.addGroupInviteReferrer(groupThreadId, inviter.hexString) + storage.insertGroupInviteControlMessage( + inviteMessageTimestamp, + inviter.hexString, + groupId, + groupName + ) + } + } + + override suspend fun handleInviteResponse( + groupId: AccountId, + sender: AccountId, + approved: Boolean + ): Unit = withContext(dispatcher) { + if (!approved) { + // We should only see approved coming through + return@withContext + } + + val adminKey = configFactory.getGroup(groupId)?.adminKey + if (adminKey == null || adminKey.isEmpty()) { + return@withContext // We don't have the admin key, we can't process the invite response + } + + configFactory.withMutableGroupConfigs(groupId) { configs -> + val member = configs.groupMembers.get(sender.hexString) + if (member != null) { + configs.groupMembers.set(member.setAccepted()) + } else { + Log.e(TAG, "User wasn't in the group membership to add!") + } + } + } + + override suspend fun handleKicked(groupId: AccountId): Unit = withContext(dispatcher) { + Log.d(TAG, "We were kicked from the group, delete and stop polling") + + // Stop polling the group immediately + pollerFactory.pollerFor(groupId)?.stop() + + val userId = requireNotNull(storage.getUserPublicKey()) { "No current user available" } + val group = configFactory.getGroup(groupId) ?: return@withContext + + // Retrieve the group name one last time from the group info, + // as we are going to clear the keys, we won't have the chance to + // read the group name anymore. + val groupName = configFactory.withGroupConfigs(groupId) { configs -> + configs.groupInfo.getName() + } ?: group.name + + configFactory.withMutableUserConfigs { + it.userGroups.set( + group.copy( + authData = null, + adminKey = null, + name = groupName + ) + ) + } + + // Clear all messages we have from the group + val threadId = storage.getThreadId(Address.fromSerialized(groupId.hexString)) + if (threadId != null) { + storage.clearMessages(threadId) + } + + // Insert a message to indicate we were kicked + storage.insertIncomingInfoMessage( + context = application, + senderPublicKey = userId, + groupID = groupId.hexString, + type = SignalServiceGroup.Type.KICKED, + name = groupName, + members = emptyList(), + admins = emptyList(), + sentTimestamp = clock.currentTimeMills(), + ) + } + + override suspend fun setName(groupId: AccountId, newName: String): Unit = + withContext(dispatcher) { + val adminKey = requireAdminAccess(groupId) + + val nameChanged = configFactory.withMutableGroupConfigs(groupId) { configs -> + if (configs.groupInfo.getName() != newName) { + configs.groupInfo.setName(newName) + true + } else { + false + } + } + + if (!nameChanged) { + return@withContext + } + + val timestamp = clock.currentTimeMills() + val signature = SodiumUtilities.sign( + buildInfoChangeVerifier(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), + 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, Destination.ClosedGroup(groupId.hexString), false) + .await() + storage.insertGroupInfoChange(message, groupId) + } + + override suspend fun requestMessageDeletion( + groupId: AccountId, + messageHashes: Set + ): Unit = withContext(dispatcher) { + // To delete messages from a group, there are a few considerations: + // 1. Messages are stored on every member's device, we need a way to ask them to delete their stored messages + // 2. Messages are also stored on the group swarm, only the group admin can delete them + // So we will send a group message to ask members to delete the messages, + // meanwhile, if we are admin we can just delete those messages from the group swarm, and otherwise + // the admins can pick up the group message and delete the messages on our behalf. + + val group = requireNotNull(configFactory.getGroup(groupId)) { + "Group doesn't exist" + } + val userPubKey = requireNotNull(storage.getUserPublicKey()) { "No current user available" } + + // Check if we can actually delete these messages + check( + group.hasAdminKey() || + storage.ensureMessageHashesAreSender( + messageHashes, + userPubKey, + groupId.hexString + ) + ) { + "Cannot delete messages that are not sent by us" + } + + // If we are admin, we can delete the messages from the group swarm + group.adminKey?.let { adminKey -> + SnodeAPI.deleteMessage( + publicKey = groupId.hexString, + swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), + serverHashes = messageHashes.toList() + ) + } + + // Construct a message to ask members to delete the messages, sign if we are admin, then send + val timestamp = clock.currentTimeMills() + val signature = group.adminKey?.let { key -> + SodiumUtilities.sign( + buildDeleteMemberContentSignature( + memberIds = emptyList(), + messageHashes, + timestamp + ), + key + ) + } + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setDeleteMemberContent( + GroupUpdateDeleteMemberContentMessage.newBuilder() + .addAllMessageHashes(messageHashes) + .let { + if (signature != null) it.setAdminSignature( + ByteString.copyFrom( + signature + ) + ) + else it + } + ) + .build() + ).apply { + sentTimestamp = timestamp + } + + MessageSender.send(message, Destination.ClosedGroup(groupId.hexString), false).await() + } + + override suspend fun handleDeleteMemberContent( + groupId: AccountId, + deleteMemberContent: GroupUpdateDeleteMemberContentMessage, + timestamp: Long, + sender: AccountId, + senderIsVerifiedAdmin: Boolean, + ): Unit = withContext(dispatcher) { + val threadId = + requireNotNull(storage.getThreadId(Address.fromSerialized(groupId.hexString))) { + "No thread ID found for the group" + } + + val hashes = deleteMemberContent.messageHashesList + val memberIds = deleteMemberContent.memberSessionIdsList + + if (hashes.isNotEmpty()) { + // If the sender is a verified admin, or the sender is the actual sender of the messages, + // we can mark them as deleted locally. + if (senderIsVerifiedAdmin || + storage.ensureMessageHashesAreSender( + hashes.toSet(), + sender.hexString, + groupId.hexString + )) { + // We'll delete everything the admin says + messageDataProvider.markMessagesAsDeleted( + threadId = threadId, + serverHashes = hashes, + displayedMessage = application.getString( + R.string.deleteMessageDeletedGlobally + ) + ) + } + } + + // To be able to delete a user's messages, the sender must be a verified admin + if (memberIds.isNotEmpty() && senderIsVerifiedAdmin) { + for (member in memberIds) { + messageDataProvider.markUserMessagesAsDeleted(threadId, timestamp, member, application.getString( + R.string.deleteMessageDeletedGlobally + )) + } + } + + val adminKey = configFactory.getGroup(groupId)?.adminKey + if (!senderIsVerifiedAdmin && adminKey != null && hashes.isNotEmpty()) { + // If the deletion request comes from a non-admin, and we as an admin, will also delete + // the content from the swarm, provided that the messages are actually sent by that user + if (storage.ensureMessageHashesAreSender( + hashes.toSet(), + sender.hexString, + groupId.hexString + ) + ) { + SnodeAPI.deleteMessage( + groupId.hexString, + OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), + hashes + ) + } + + // The non-admin user shouldn't be able to delete other user's messages so we will + // ignore the memberIds in the message + } + } + + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { + val firstError = this.results.firstOrNull { it.code != 200 } + require(firstError == null) { "$errorMessage: ${firstError!!.body}" } + } + + private val Profile.profilePicture: UserPic? + get() { + val url = this.profilePictureURL + val key = this.profileKey + return if (url != null && key != null) { + UserPic(url, key) + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index bcf12b3920b..8590372cf48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -116,9 +116,6 @@ class JoinCommunityFragment : Fragment() { GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded( - requireContext() - ) withContext(Dispatchers.Main) { val recipient = Recipient.from( requireContext(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 4b6f73bd2af..d3fbac350c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -17,7 +17,6 @@ import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPolle import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities object OpenGroupManager { private val executorService = Executors.newScheduledThreadPool(4) @@ -139,8 +138,10 @@ object OpenGroupManager { pollers.remove(server) } } - configFactory.userGroups?.eraseCommunity(server, room) - configFactory.convoVolatile?.eraseCommunity(server, room) + configFactory.withMutableUserConfigs { + it.userGroups.eraseCommunity(server, room) + it.convoInfoVolatile.eraseCommunity(server, room) + } // Delete storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -150,7 +151,6 @@ object OpenGroupManager { lokiThreadDB.removeOpenGroupChat(threadID) storage.deleteConversation(threadID) // Must be invoked on a background thread GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } catch (e: Exception) { Log.e("Loki", "Failed to leave (delete) community", e) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt new file mode 100644 index 00000000000..fc2d7560876 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.truncateIdForDisplay +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.home.search.getSearchName + +@OptIn(FlowPreview::class) +@HiltViewModel(assistedFactory = SelectContactsViewModel.Factory::class) +class SelectContactsViewModel @AssistedInject constructor( + private val storage: StorageProtocol, + private val configFactory: ConfigFactory, + @ApplicationContext private val appContext: Context, + @Assisted private val excludingAccountIDs: Set, + @Assisted private val scope: CoroutineScope +) : ViewModel() { + // Input: The search query + private val mutableSearchQuery = MutableStateFlow("") + + // Input: The selected contact account IDs + private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet()) + + // Output: The search query + val searchQuery: StateFlow get() = mutableSearchQuery + + // Output: the contact items to display and select from + val contacts: StateFlow> = combine( + observeContacts(), + mutableSearchQuery.debounce(100L), + mutableSelectedContactAccountIDs, + ::filterContacts + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Output + val currentSelected: Set + get() = contacts.value + .asSequence() + .filter { it.selected } + .map { it.accountID } + .toSet() + + override fun onCleared() { + super.onCleared() + + scope.cancel() + } + + private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) + .debounce(100L) + .onStart { emit(Unit) } + .map { + withContext(Dispatchers.Default) { + val allContacts = configFactory.withUserConfigs { + it.contacts.all().filter { it.approvedMe } + } + + if (excludingAccountIDs.isEmpty()) { + allContacts + } else { + allContacts.filterNot { AccountId(it.id) in excludingAccountIDs } + }.map { Recipient.from(appContext, Address.fromSerialized(it.id), false) } + } + } + + + private fun filterContacts( + contacts: Collection, + query: String, + selectedAccountIDs: Set + ): List { + return contacts + .asSequence() + .filter { query.isBlank() || it.getSearchName().contains(query, ignoreCase = true) } + .map { contact -> + val accountId = AccountId(contact.address.serialize()) + ContactItem( + name = contact.getSearchName(), + accountID = accountId, + selected = selectedAccountIDs.contains(accountId), + ) + } + .toList() + } + + fun onSearchQueryChanged(query: String) { + mutableSearchQuery.value = query + } + + fun onContactItemClicked(accountID: AccountId) { + val newSet = mutableSelectedContactAccountIDs.value.toHashSet() + if (!newSet.remove(accountID)) { + newSet.add(accountID) + } + mutableSelectedContactAccountIDs.value = newSet + } + + @AssistedFactory + interface Factory { + fun create( + excludingAccountIDs: Set = emptySet(), + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + ): SelectContactsViewModel + } +} + +data class ContactItem( + val accountID: AccountId, + val name: String, + val selected: Boolean, +) 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 00000000000..bd446fa1080 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +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.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.ui.Avatar +import org.thoughtcrime.securesms.ui.components.RadioButton +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + + +@Composable +fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .background(LocalColors.current.warning) + ) { + Text( + text = stringResource(R.string.groupInviteVersion), + color = LocalColors.current.textAlert, + style = LocalType.current.small, + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier.padding( + horizontal = LocalDimensions.current.spacing, + vertical = LocalDimensions.current.xxxsSpacing + ) + ) + } +} + +fun LazyListScope.multiSelectMemberList( + contacts: List, + modifier: Modifier = Modifier, + onContactItemClicked: (accountId: AccountId) -> Unit, + enabled: Boolean = true, +) { + items(contacts.size) { index -> + val contact = contacts[index] + Column(modifier = modifier) { + if (index == 0) { + // Show top divider for the first item only + HorizontalDivider(color = LocalColors.current.borders) + } + + RadioButton( + onClick = { onContactItemClicked(contact.accountID) }, + selected = contact.selected, + enabled = enabled, + contentPadding = PaddingValues( + vertical = LocalDimensions.current.xxsSpacing, + horizontal = LocalDimensions.current.smallSpacing + ) + ) { + ContactPhoto(contact.accountID) + Spacer(modifier = Modifier.size(LocalDimensions.current.smallSpacing)) + MemberName(name = contact.name) + } + + HorizontalDivider(color = LocalColors.current.borders) + } + } +} + +@Composable +fun RowScope.MemberName( + name: String, + modifier: Modifier = Modifier +) = Text( + text = name, + style = LocalType.current.h8, + color = LocalColors.current.text, + modifier = modifier + .weight(1f) + .align(CenterVertically) +) + + +@Composable +fun ContactPhoto(sessionId: AccountId) { + Avatar(Address.fromSerialized(sessionId.hexString), + modifier = Modifier.size(LocalDimensions.current.iconLarge)) +} + + +@Preview +@Composable +fun PreviewMemberList() { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + + PreviewTheme { + LazyColumn { + multiSelectMemberList( + contacts = listOf( + ContactItem( + accountID = AccountId(random), + name = "Person", + selected = false, + ), + ContactItem( + accountID = AccountId(random), + name = "Cow", + selected = true, + ) + ), + onContactItemClicked = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt new file mode 100644 index 00000000000..8fdf5566289 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import network.loki.messenger.R +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.CreateGroupEvent +import org.thoughtcrime.securesms.groups.CreateGroupViewModel +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.LoadingArcOr +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + + +@Composable +fun CreateGroupScreen( + onNavigateToConversationScreen: (threadID: Long) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val viewModel: CreateGroupViewModel = hiltViewModel() + val context = LocalContext.current + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + when (event) { + is CreateGroupEvent.NavigateToConversation -> { + onClose() + onNavigateToConversationScreen(event.threadID) + } + + is CreateGroupEvent.Error -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + CreateGroup( + groupName = viewModel.groupName.collectAsState().value, + onGroupNameChanged = viewModel::onGroupNameChanged, + groupNameError = viewModel.groupNameError.collectAsState().value, + contactSearchQuery = viewModel.selectContactsViewModel.searchQuery.collectAsState().value, + onContactSearchQueryChanged = viewModel.selectContactsViewModel::onSearchQueryChanged, + onContactItemClicked = viewModel.selectContactsViewModel::onContactItemClicked, + showLoading = viewModel.isLoading.collectAsState().value, + items = viewModel.selectContactsViewModel.contacts.collectAsState().value, + onCreateClicked = viewModel::onCreateClicked, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateGroup( + groupName: String, + onGroupNameChanged: (String) -> Unit, + groupNameError: String, + contactSearchQuery: String, + onContactSearchQueryChanged: (String) -> Unit, + onContactItemClicked: (accountID: AccountId) -> Unit, + showLoading: Boolean, + items: List, + onCreateClicked: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val focusManager = LocalFocusManager.current + + Scaffold( + containerColor = LocalColors.current.backgroundSecondary, + topBar = { + BackAppBar( + title = stringResource(id = R.string.groupCreate), + backgroundColor = LocalColors.current.backgroundSecondary, + onBack = onBack, + ) + } + ) { paddings -> + Box(modifier = modifier.padding(paddings)) { + Column( + modifier = modifier.padding(vertical = LocalDimensions.current.xsSpacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SessionOutlinedTextField( + text = groupName, + onChange = onGroupNameChanged, + placeholder = stringResource(R.string.groupNameEnter), + textStyle = LocalType.current.base, + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing), + error = groupNameError.takeIf { it.isNotBlank() }, + enabled = !showLoading, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + onContinue = focusManager::clearFocus + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + SearchBar( + query = contactSearchQuery, + onValueChanged = onContactSearchQueryChanged, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing), + enabled = !showLoading + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + BottomFadingEdgeBox( + modifier = Modifier.weight(1f) + .nestedScroll(rememberNestedScrollInteropConnection()), + fadingColor = LocalColors.current.backgroundSecondary + ) { bottomContentPadding -> + LazyColumn( + contentPadding = PaddingValues(bottom = bottomContentPadding)) { + multiSelectMemberList( + contacts = items, + onContactItemClicked = onContactItemClicked, + enabled = !showLoading + ) + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + PrimaryOutlineButton( + onClick = onCreateClicked, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .widthIn(min = LocalDimensions.current.minButtonWidth) + ) { + LoadingArcOr(loading = showLoading) { + Text(stringResource(R.string.create)) + } + } + } + } + + } + +} + +@Preview +@Composable +private fun CreateGroupPreview( +) { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val previewMembers = listOf( + ContactItem(accountID = AccountId(random), name = "Alice", false), + ContactItem(accountID = AccountId(random), name = "Bob", true), + ) + + PreviewTheme { + CreateGroup( + groupName = "", + onGroupNameChanged = {}, + groupNameError = "", + contactSearchQuery = "", + onContactSearchQueryChanged = {}, + onContactItemClicked = {}, + showLoading = false, + items = previewMembers, + onCreateClicked = {}, + onBack = {}, + modifier = Modifier.background(LocalColors.current.backgroundSecondary), + ) + } + +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt new file mode 100644 index 00000000000..26ff4aa2cf1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -0,0 +1,525 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.widget.Toast +import androidx.compose.animation.Crossfade +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.squareup.phrase.Phrase +import kotlinx.serialization.Serializable +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.EditGroupViewModel +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.BottomOptionsDialog +import org.thoughtcrime.securesms.ui.components.BottomOptionsDialogItem +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + +@Composable +fun EditGroupScreen( + groupId: AccountId, + onFinish: () -> Unit, +) { + val navController = rememberNavController() + val viewModel = hiltViewModel { factory -> + factory.create(groupId) + } + + NavHost(navController = navController, startDestination = RouteEditGroup) { + composable { + EditGroup( + onBackClick = onFinish, + onAddMemberClick = { navController.navigate(RouteSelectContacts) }, + onResendInviteClick = viewModel::onResendInviteClicked, + onPromoteClick = viewModel::onPromoteContact, + onRemoveClick = viewModel::onRemoveContact, + onEditNameClicked = viewModel::onEditNameClicked, + onEditNameCancelClicked = viewModel::onCancelEditingNameClicked, + onEditNameConfirmed = viewModel::onEditNameConfirmClicked, + onEditingNameValueChanged = viewModel::onEditingNameChanged, + editingName = viewModel.editingName.collectAsState().value, + members = viewModel.members.collectAsState().value, + groupName = viewModel.groupName.collectAsState().value, + showAddMembers = viewModel.showAddMembers.collectAsState().value, + canEditName = viewModel.canEditGroupName.collectAsState().value, + onResendPromotionClick = viewModel::onResendPromotionClicked, + showingError = viewModel.error.collectAsState().value, + onErrorDismissed = viewModel::onDismissError, + ) + } + + composable { + SelectContactsScreen( + excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection, + onDoneClicked = { + viewModel.onContactSelected(it) + navController.popBackStack() + }, + onBackClicked = { navController.popBackStack() }, + ) + } + } + +} + +@Serializable +private object RouteEditGroup + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditGroup( + onBackClick: () -> Unit, + onAddMemberClick: () -> Unit, + onResendInviteClick: (accountId: AccountId) -> Unit, + onResendPromotionClick: (accountId: AccountId) -> Unit, + onPromoteClick: (accountId: AccountId) -> Unit, + onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, + onEditingNameValueChanged: (String) -> Unit, + editingName: String?, + onEditNameClicked: () -> Unit, + onEditNameConfirmed: () -> Unit, + onEditNameCancelClicked: () -> Unit, + canEditName: Boolean, + groupName: String, + members: List, + showAddMembers: Boolean, + showingError: String?, + onErrorDismissed: () -> Unit, +) { + val (showingOptionsDialogForMember, setShowingBottomModelForMember) = remember { + mutableStateOf(null) + } + + val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { + mutableStateOf(null) + } + + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.groupEdit), + onBack = onBackClick, + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + + GroupMinimumVersionBanner() + + // Group name title + Crossfade(editingName != null, label = "Editable group name") { showNameEditing -> + if (showNameEditing) { + GroupNameContainer { + IconButton( + modifier = Modifier.size(LocalDimensions.current.spacing), + onClick = onEditNameCancelClicked) { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = stringResource(R.string.AccessibilityId_cancel), + tint = LocalColors.current.text, + ) + } + + SessionOutlinedTextField( + modifier = Modifier.widthIn( + min = LocalDimensions.current.mediumSpacing, + max = 240.dp + ), + text = editingName.orEmpty(), + onChange = onEditingNameValueChanged, + textStyle = LocalType.current.h8, + singleLine = true, + innerPadding = PaddingValues( + horizontal = LocalDimensions.current.spacing, + vertical = LocalDimensions.current.smallSpacing + ) + ) + + IconButton( + modifier = Modifier.size(LocalDimensions.current.spacing), + onClick = onEditNameConfirmed) { + Icon( + painter = painterResource(R.drawable.check), + contentDescription = stringResource(R.string.AccessibilityId_confirm), + tint = LocalColors.current.text, + ) + } + } + + + } else { + GroupNameContainer { + Text( + text = groupName, + style = LocalType.current.h4, + textAlign = TextAlign.Center, + ) + + if (canEditName) { + IconButton(onClick = onEditNameClicked) { + Icon( + painterResource(R.drawable.ic_baseline_edit_24), + contentDescription = stringResource(R.string.groupName), + tint = LocalColors.current.text, + ) + } + } + } + } + } + + // Header & Add member button + Row( + modifier = Modifier.padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + verticalAlignment = CenterVertically + ) { + Text( + stringResource(R.string.groupMembers), + modifier = Modifier.weight(1f), + style = LocalType.current.large, + color = LocalColors.current.text + ) + + if (showAddMembers) { + PrimaryOutlineButton( + stringResource(R.string.membersInvite), + onClick = onAddMemberClick + ) + } + } + + + // List of members + LazyColumn(modifier = Modifier) { + items(members) { member -> + // Each member's view + MemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + clickable = member.canEdit, + onClick = { setShowingBottomModelForMember(member) } + ) + } + } + } + } + + if (showingOptionsDialogForMember != null) { + MemberOptionsDialog( + onDismissRequest = { setShowingBottomModelForMember(null) }, + onRemove = { + setShowingConfirmRemovingMember(showingOptionsDialogForMember) + setShowingBottomModelForMember(null) + }, + onPromote = { + setShowingBottomModelForMember(null) + onPromoteClick(showingOptionsDialogForMember.accountId) + }, + onResendInvite = { + setShowingBottomModelForMember(null) + onResendInviteClick(showingOptionsDialogForMember.accountId) + }, + onResendPromotion = { + setShowingBottomModelForMember(null) + onResendPromotionClick(showingOptionsDialogForMember.accountId) + }, + member = showingOptionsDialogForMember, + ) + } + + if (showingConfirmRemovingMember != null) { + ConfirmRemovingMemberDialog( + onDismissRequest = { + setShowingConfirmRemovingMember(null) + }, + onConfirmed = onRemoveClick, + member = showingConfirmRemovingMember, + groupName = groupName, + ) + } + + val context = LocalContext.current + + LaunchedEffect(showingError) { + if (showingError != null) { + Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() + onErrorDismissed() + } + } +} + +@Composable +private fun GroupNameContainer(content: @Composable RowScope.() -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp), + horizontalArrangement = Arrangement.spacedBy( + LocalDimensions.current.xxxsSpacing, + Alignment.CenterHorizontally + ), + verticalAlignment = CenterVertically, + content = content + ) +} + +@Composable +private fun ConfirmRemovingMemberDialog( + onConfirmed: (accountId: AccountId, removeMessages: Boolean) -> Unit, + onDismissRequest: () -> Unit, + member: GroupMemberState, + groupName: String, +) { + val context = LocalContext.current + val buttons = buildList { + this += DialogButtonModel( + text = GetString(R.string.remove), + color = LocalColors.current.danger, + onClick = { onConfirmed(member.accountId, false) } + ) + + if (BuildConfig.DEBUG) { + this += DialogButtonModel( + text = GetString("Remove with messages"), + color = LocalColors.current.danger, + onClick = { onConfirmed(member.accountId, true) } + ) + } + + this += DialogButtonModel( + text = GetString(R.string.cancel), + onClick = onDismissRequest, + ) + } + + AlertDialog( + onDismissRequest = onDismissRequest, + text = Phrase.from(context, R.string.groupRemoveDescription) + .put(NAME_KEY, member.name) + .put(GROUP_NAME_KEY, groupName) + .format() + .toString(), + title = stringResource(R.string.remove), + buttons = buttons + ) +} + +@Composable +private fun MemberOptionsDialog( + member: GroupMemberState, + onRemove: () -> Unit, + onPromote: () -> Unit, + onResendInvite: () -> Unit, + onResendPromotion: () -> Unit, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + + val options = remember(member) { + buildList { + if (member.canRemove) { + this += BottomOptionsDialogItem( + title = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1), + iconRes = R.drawable.ic_delete_24, + onClick = onRemove + ) + } + + if (BuildConfig.DEBUG && member.canPromote) { + this += BottomOptionsDialogItem( + title = context.getString(R.string.adminPromoteToAdmin), + iconRes = R.drawable.ic_profile_default, + onClick = onPromote + ) + } + + if (BuildConfig.DEBUG && member.canResendInvite) { + this += BottomOptionsDialogItem( + title = "Resend invitation", + iconRes = R.drawable.ic_mail, + onClick = onResendInvite + ) + } + + if (BuildConfig.DEBUG && member.canResendPromotion) { + this += BottomOptionsDialogItem( + title = "Resend promotion", + iconRes = R.drawable.ic_mail, + onClick = onResendPromotion + ) + } + } + } + + BottomOptionsDialog( + items = options, + onDismissRequest = onDismissRequest + ) +} + +@Composable +private fun MemberItem( + clickable: Boolean, + onClick: (accountId: AccountId) -> Unit, + member: GroupMemberState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clickable(enabled = clickable, onClick = { onClick(member.accountId) }) + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xsSpacing + ), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + verticalAlignment = CenterVertically, + ) { + ContactPhoto(member.accountId) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) + ) { + + Text( + style = LocalType.current.large, + text = member.name, + color = LocalColors.current.text + ) + + if (member.status.isNotEmpty()) { + Text( + text = member.status, + style = LocalType.current.small, + color = if (member.highlightStatus) { + LocalColors.current.danger + } else { + LocalColors.current.textSecondary + }, + ) + } + } + + if (member.canEdit) { + Icon( + painter = painterResource(R.drawable.ic_circle_dot_dot_dot), + contentDescription = stringResource(R.string.AccessibilityId_sessionSettings) + ) + } + } +} + + +@Preview +@Composable +private fun EditGroupPreview() { + PreviewTheme { + val oneMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), + name = "Test User", + status = "Invited", + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + ) + val twoMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), + name = "Test User 2", + status = "Promote failed", + highlightStatus = true, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + ) + val threeMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), + name = "Test User 3", + status = "", + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + ) + + val (editingName, setEditingName) = remember { mutableStateOf(null) } + + EditGroup( + onBackClick = {}, + onAddMemberClick = {}, + onResendInviteClick = {}, + onPromoteClick = {}, + onRemoveClick = { _, _ -> }, + onEditNameCancelClicked = { + setEditingName(null) + }, + onEditNameConfirmed = { + setEditingName(null) + }, + onEditNameClicked = { + setEditingName("Test Group") + }, + editingName = editingName, + onEditingNameValueChanged = setEditingName, + members = listOf(oneMember, twoMember, threeMember), + canEditName = true, + groupName = "Test", + showAddMembers = true, + onResendPromotionClick = {}, + showingError = "Error", + onErrorDismissed = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt new file mode 100644 index 00000000000..225a5585db5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt @@ -0,0 +1,141 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.serialization.Serializable +import network.loki.messenger.R +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.SelectContactsViewModel +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + + +@Serializable +object RouteSelectContacts + +@Composable +fun SelectContactsScreen( + excludingAccountIDs: Set = emptySet(), + onDoneClicked: (selectedContacts: Set) -> Unit, + onBackClicked: () -> Unit, +) { + val viewModel = hiltViewModel { factory -> + factory.create(excludingAccountIDs) + } + + SelectContacts( + contacts = viewModel.contacts.collectAsState().value, + onContactItemClicked = viewModel::onContactItemClicked, + searchQuery = viewModel.searchQuery.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onDoneClicked = { onDoneClicked(viewModel.currentSelected) }, + onBack = onBackClicked, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectContacts( + contacts: List, + onContactItemClicked: (accountId: AccountId) -> Unit, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onDoneClicked: () -> Unit, + onBack: () -> Unit, + @StringRes okButtonResId: Int = R.string.ok +) { + Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) { + BackAppBar( + title = stringResource(id = R.string.contactSelect), + onBack = onBack, + ) + + GroupMinimumVersionBanner() + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + val scrollState = rememberLazyListState() + + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = bottomContentPadding), + ) { + multiSelectMemberList( + contacts = contacts, + onContactItemClicked = onContactItemClicked, + ) + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + PrimaryOutlineButton( + onClick = onDoneClicked, + modifier = Modifier + .padding(vertical = LocalDimensions.current.spacing) + .defaultMinSize(minWidth = LocalDimensions.current.minButtonWidth), + ) { + Text( + stringResource(id = okButtonResId) + ) + } + } + } + +} + +@Preview +@Composable +private fun PreviewSelectContacts() { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val contacts = List(20) { + ContactItem( + accountID = AccountId(random), + name = "User $it", + selected = it % 3 == 0, + ) + } + + PreviewTheme { + SelectContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onDoneClicked = {}, + onBack = {}, + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt new file mode 100644 index 00000000000..e54259f6646 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -0,0 +1,274 @@ +package org.thoughtcrime.securesms.groups.handler + +import android.content.Context +import android.os.SystemClock +import com.google.protobuf.ByteString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.libsession_util.ReadableGroupKeysConfig +import network.loki.messenger.libsession_util.util.GroupMember +import network.loki.messenger.libsession_util.util.Sodium +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.MessageAuthentication +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.waitUntilGroupConfigsPushed +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "RemoveGroupMemberHandler" +private const val MIN_PROCESS_INTERVAL_MILLS = 1_000L + +/** + * This handler is responsible for processing pending group member removals. + * + * It automatically does so by listening to the config updates changes and checking for any pending removals. + */ +@Singleton +class RemoveGroupMemberHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val configFactory: ConfigFactoryProtocol, + private val textSecurePreferences: TextSecurePreferences, + private val groupManager: GroupManagerV2, + private val clock: SnodeClock, + private val messageDataProvider: MessageDataProvider, + private val storage: StorageProtocol, +) { + private var job: Job? = null + + fun start() { + require(job == null) { "Already started" } + + job = GlobalScope.launch { + while (true) { + // Make sure we have a local number before we start processing + textSecurePreferences.watchLocalNumber().first { it != null } + + val processStartedAt = SystemClock.uptimeMillis() + + try { + processPendingMemberRemoval() + } catch (e: Exception) { + Log.e(TAG, "Error processing pending member removal", e) + } + + configFactory.configUpdateNotifications.firstOrNull() + + // Make sure we don't process too often. As some of the config changes don't apply + // to us, but we have no way to tell if it does or not. The safest way is to process + // everytime any config changes, with a minimum interval. + val delayMills = + MIN_PROCESS_INTERVAL_MILLS - (SystemClock.uptimeMillis() - processStartedAt) + + if (delayMills > 0) { + delay(delayMills) + } + } + } + } + + private suspend fun processPendingMemberRemoval() { + configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + .asSequence() + .filter { it.hasAdminKey() } + .forEach { group -> + processPendingRemovalsForGroup(group.groupAccountId, group.adminKey!!) + } + } + + private suspend fun processPendingRemovalsForGroup( + groupAccountId: AccountId, + adminKey: ByteArray + ) { + val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupAccountId, adminKey) + + val (pendingRemovals, batchCalls) = configFactory.withGroupConfigs(groupAccountId) { configs -> + val pendingRemovals = configs.groupMembers.all().filter { it.removed } + if (pendingRemovals.isEmpty()) { + // Skip if there are no pending removals + return@withGroupConfigs pendingRemovals to emptyList() + } + + Log.d(TAG, "Processing ${pendingRemovals.size} pending removals for group") + + // Perform a sequential call to group snode to: + // 1. Revoke the member's sub key (by adding the key to a "revoked list" under the hood) + // 2. Send a message to a special namespace on the group to inform the removed members they have been removed + // 3. Conditionally, send a `GroupUpdateDeleteMemberContent` to the group so the message deletion + // can be performed by everyone in the group. + val calls = ArrayList(3) + + // Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful. + calls += checkNotNull( + SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = pendingRemovals.map { + configs.groupKeys.getSubAccountToken(AccountId(it.sessionId)) + } + ) + ) { "Fail to create a revoke request" } + + // Call No 2. Send a "kicked" message to the revoked namespace + calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.REVOKED_GROUP_MESSAGES(), + message = buildGroupKickMessage( + groupAccountId.hexString, + pendingRemovals, + configs.groupKeys, + adminKey + ), + auth = groupAuth, + ) + + // Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent` + if (pendingRemovals.any { it.shouldRemoveMessages }) { + calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.CLOSED_GROUP_MESSAGES(), + message = buildDeleteGroupMemberContentMessage( + adminKey = adminKey, + groupAccountId = groupAccountId.hexString, + memberSessionIDs = pendingRemovals + .asSequence() + .filter { it.shouldRemoveMessages } + .map { it.sessionId }, + ), + auth = groupAuth, + ) + } + + pendingRemovals to (calls as List) + } + + if (pendingRemovals.isEmpty() || batchCalls.isEmpty()) { + return + } + + val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await() + val response = + SnodeAPI.getBatchResponse(node, groupAccountId.hexString, batchCalls, sequence = true) + + val firstError = response.results.firstOrNull { !it.isSuccessful } + check(firstError == null) { + "Error processing pending removals for group: code = ${firstError?.code}, body = ${firstError?.body}" + } + + Log.d(TAG, "Essential steps for group removal are done") + + // The essential part of the operation has been successful once we get to this point, + // now we can go ahead and update the configs + configFactory.withMutableGroupConfigs(groupAccountId) { configs -> + pendingRemovals.forEach(configs.groupMembers::erase) + configs.rekey() + } + + configFactory.waitUntilGroupConfigsPushed(groupAccountId) + + Log.d(TAG, "Group configs updated") + + // Try to delete members' message. It's ok to fail as they will be re-tried in different + // cases (a.k.a the GroupUpdateDeleteMemberContent message handling) and could be by different admins. + val deletingMessagesForMembers = pendingRemovals.filter { it.shouldRemoveMessages } + if (deletingMessagesForMembers.isNotEmpty()) { + val threadId = storage.getThreadId(Address.fromSerialized(groupAccountId.hexString)) + if (threadId != null) { + val until = clock.currentTimeMills() + for (member in deletingMessagesForMembers) { + try { + messageDataProvider.markUserMessagesAsDeleted( + threadId = threadId, + until = until, + sender = member.sessionId, + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + ) + } catch (e: Exception) { + Log.e(TAG, "Error deleting messages for removed member", e) + } + } + } + } + } + + private fun buildDeleteGroupMemberContentMessage( + adminKey: ByteArray, + groupAccountId: String, + memberSessionIDs: Sequence + ): SnodeMessage { + val timestamp = clock.currentTimeMills() + + return MessageSender.buildWrappedMessageToSnode( + destination = Destination.ClosedGroup(groupAccountId), + message = GroupUpdated( + SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder() + .setDeleteMemberContent( + SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage.newBuilder() + .apply { + for (id in memberSessionIDs) { + addMemberSessionIds(id) + } + } + .setAdminSignature( + ByteString.copyFrom( + SodiumUtilities.sign( + MessageAuthentication.buildDeleteMemberContentSignature( + memberIds = memberSessionIDs.map { AccountId(it) } + .toList(), + messageHashes = emptyList(), + timestamp = timestamp, + ), adminKey + ) + ) + ) + ) + .build() + ).apply { sentTimestamp = timestamp }, + isSyncMessage = false + ) + } + + private fun buildGroupKickMessage( + groupAccountId: String, + pendingRemovals: List, + keys: ReadableGroupKeysConfig, + adminKey: ByteArray + ) = SnodeMessage( + recipient = groupAccountId, + data = Base64.encodeBytes( + Sodium.encryptForMultipleSimple( + messages = Array(pendingRemovals.size) { + AccountId(pendingRemovals[it].sessionId).pubKeyBytes + .plus(keys.currentGeneration().toString().toByteArray()) + }, + recipients = Array(pendingRemovals.size) { + AccountId(pendingRemovals[it].sessionId).pubKeyBytes + }, + ed25519SecretKey = adminKey, + domain = Sodium.KICKED_DOMAIN + ) + ), + ttl = SnodeMessage.DEFAULT_TTL, + timestamp = clock.currentTimeMills() + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index ed7abc7fcd0..2f53e845463 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -6,10 +6,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.core.widget.TextViewCompat import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding +import org.session.libsession.utilities.GroupRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread @@ -22,7 +24,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // is not the best idea. It doesn't survive configuration change. // We should be dealing with IDs and all sorts of serializable data instead // if we want to use dialog fragments properly. + lateinit var publicKey: String lateinit var thread: ThreadRecord + var group: GroupRecord? = null @Inject lateinit var configFactory: ConfigFactory @@ -63,7 +67,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto super.onViewCreated(view, savedInstanceState) if (!this::thread.isInitialized) { return dismiss() } val recipient = thread.recipient - if (!recipient.isGroupRecipient && !recipient.isLocalNumber) { + val isCurrentUserInGroup = group?.members?.map { it.toString() }?.contains(publicKey) ?: false + if (!recipient.isGroupOrCommunityRecipient && !recipient.isLocalNumber) { binding.detailsTextView.visibility = View.VISIBLE binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE binding.blockTextView.visibility = if (recipient.isBlocked) View.GONE else View.VISIBLE @@ -73,7 +78,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto } else { binding.detailsTextView.visibility = View.GONE } - binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE + binding.copyConversationId.visibility = if (!recipient.isGroupOrCommunityRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE binding.copyConversationId.setOnClickListener(this) binding.copyCommunityUrl.visibility = if (recipient.isCommunityRecipient) View.VISIBLE else View.GONE binding.copyCommunityUrl.setOnClickListener(this) @@ -81,36 +86,44 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber binding.unMuteNotificationsTextView.setOnClickListener(this) binding.muteNotificationsTextView.setOnClickListener(this) - binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted + binding.notificationsTextView.isVisible = recipient.isGroupOrCommunityRecipient && !recipient.isMuted binding.notificationsTextView.setOnClickListener(this) // delete binding.deleteTextView.apply { setOnClickListener(this@ConversationOptionsBottomSheet) - // the text and content description will change depending on the type - when{ + val drawableStartRes: Int + + // the text, content description and icon will change depending on the type + when { // groups and communities - recipient.isGroupRecipient -> { + recipient.isGroupOrCommunityRecipient -> { text = context.getString(R.string.leave) contentDescription = context.getString(R.string.AccessibilityId_leave) + drawableStartRes = R.drawable.ic_log_out } // note to self recipient.isLocalNumber -> { text = context.getString(R.string.clear) contentDescription = context.getString(R.string.AccessibilityId_clear) + drawableStartRes = R.drawable.ic_delete_24 } // 1on1 else -> { text = context.getString(R.string.delete) contentDescription = context.getString(R.string.AccessibilityId_delete) + drawableStartRes = R.drawable.ic_delete_24 } } + + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, drawableStartRes, 0, 0, 0) } - binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true + binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || + configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) } binding.markAllAsReadTextView.setOnClickListener(this) binding.pinTextView.isVisible = !thread.isPinned binding.unpinTextView.isVisible = thread.isPinned diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 011a8ae5993..4058960e02f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.ColorDrawable -import android.text.TextUtils import android.util.AttributeSet import android.util.TypedValue import android.view.View @@ -87,9 +86,9 @@ class ConversationView : LinearLayout { val textSize = if (unreadCount < 1000) 12.0f else 10.0f binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) - || (configFactory.convoVolatile?.getConversationUnread(thread) == true) + || (configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup) + binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroupOrCommunity) val senderDisplayName = getTitle(thread.recipient) ?: thread.recipient.address.toString() binding.conversationViewDisplayNameTextView.text = senderDisplayName 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 cfbd03bb578..c62d62cfedc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -8,11 +8,8 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.text.format.DateUtils import android.widget.Toast import androidx.activity.viewModels -import androidx.annotation.PluralsRes -import androidx.annotation.StringRes import androidx.core.os.bundleOf import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -20,6 +17,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -34,67 +33,51 @@ import network.loki.messenger.databinding.ActivityHomeBinding import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfilePictureModifiedEvent +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils -import org.session.libsignal.utilities.toHexString -import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.start.StartConversationFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SessionJobDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout import org.thoughtcrime.securesms.home.search.GlobalSearchResult import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager -import org.session.libsession.utilities.truncateIdForDisplay -import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.setThemedContent -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.IP2Country -import org.thoughtcrime.securesms.util.RelativeDay import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.start -import java.io.IOException -import java.util.Calendar -import java.util.Locale import javax.inject.Inject -import kotlin.math.abs -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds // Intent extra keys so we know where we came from private const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" @@ -117,7 +100,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var configFactory: ConfigFactory - @Inject lateinit var pushRegistry: PushRegistry + @Inject lateinit var groupManagerV2: GroupManagerV2 + @Inject lateinit var lokiThreadDatabase: LokiThreadDatabase + @Inject lateinit var sessionJobDatabase: SessionJobDatabase + @Inject lateinit var clock: SnodeClock + @Inject lateinit var messageNotifier: MessageNotifier private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -141,8 +128,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) } is GlobalSearchAdapter.Model.Contact -> push { - putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized)) + putExtra( + ConversationActivityV2.ADDRESS, + model.contact.accountID.let(Address::fromSerialized) + ) } + is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId .let { Recipient.from(this, Address.fromSerialized(it), false) } .let(threadDb::getThreadIdIfExistsFor) @@ -234,12 +225,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } lifecycleScope.launchWhenStarted { - launch(Dispatchers.IO) { + launch(Dispatchers.Default) { // Double check that the long poller is up (applicationContext as ApplicationContext).startPollingIfNeeded() // update things based on TextSecurePrefs (profile info etc) // Set up remaining components if needed - pushRegistry.refresh(false) if (textSecurePreferences.getLocalNumber() != null) { OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() @@ -289,9 +279,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .request(Manifest.permission.POST_NOTIFICATIONS) .execute() } - configFactory.user - ?.takeUnless { it.isBlockCommunityMessageRequestsSet() } - ?.setCommunityMessageRequests(false) + + configFactory.withMutableUserConfigs { + if (!it.userProfile.isBlockCommunityMessageRequestsSet()) { + it.userProfile.setCommunityMessageRequests(false) + } + } } } @@ -363,7 +356,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), override fun onResume() { super.onResume() - ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) + messageNotifier.setHomeScreenVisible(true) if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) binding.profileButton.recycle() // clear cached image before update tje profilePictureView @@ -378,11 +371,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } updateLegacyConfigView() - - // Sync config changes if there are any - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) - } } override fun onPause() { @@ -439,7 +427,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), override fun onLongConversationClick(thread: ThreadRecord) { val bottomSheet = ConversationOptionsBottomSheet(this) + bottomSheet.publicKey = publicKey bottomSheet.thread = thread + bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull() bottomSheet.onViewDetailsTapped = { bottomSheet.dismiss() val userDetailsBottomSheet = UserDetailsBottomSheet() @@ -452,7 +442,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } bottomSheet.onCopyConversationId = onCopyConversationId@{ bottomSheet.dismiss() - if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) { + if (!thread.recipient.isGroupOrCommunityRecipient && !thread.recipient.isLocalNumber) { val clip = ClipData.newPlainText("Account ID", thread.recipient.address.toString()) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) @@ -460,7 +450,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else if (thread.recipient.isCommunityRecipient) { val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) - val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit + val openGroup = lokiThreadDatabase.getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager @@ -516,7 +506,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .put(NAME_KEY, thread.recipient.toShortString()) .format()) dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { storage.setBlocked(listOf(thread.recipient), true) withContext(Dispatchers.Main) { @@ -536,7 +526,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), title(R.string.blockUnblock) text(Phrase.from(context, R.string.blockUnblockName).put(NAME_KEY, thread.recipient.toShortString()).format()) dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { storage.setBlocked(listOf(thread.recipient), false) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() @@ -549,7 +539,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { if (!isMuted) { - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { recipientDatabase.setMuted(thread.recipient, 0) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() @@ -557,7 +547,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } else { showMuteDialog(this) { until -> - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() @@ -568,7 +558,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun setNotifyType(thread: ThreadRecord, newNotifyType: Int) { - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { recipientDatabase.setNotifyType(thread.recipient, newNotifyType) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() @@ -577,27 +567,41 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun setConversationPinned(threadId: Long, pinned: Boolean) { - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { storage.setPinned(threadId, pinned) homeViewModel.tryReload() } } private fun markAllAsRead(thread: ThreadRecord) { - ThreadUtils.queue { - MessagingModuleConfiguration.shared.storage.markConversationAsRead(thread.threadId, SnodeAPI.nowWithOffset) + lifecycleScope.launch(Dispatchers.Default) { + storage.markConversationAsRead(thread.threadId, clock.currentTimeMills()) } } private fun deleteConversation(thread: ThreadRecord) { val threadID = thread.threadId val recipient = thread.recipient + + if (recipient.isGroupRecipient) { + ConversationMenuHelper.leaveGroup( + context = this, + thread = recipient, + threadID = threadID, + configFactory = configFactory, + storage = storage, + groupManager = groupManagerV2, + ) + + return + } + val title: String val message: CharSequence var positiveButtonId: Int = R.string.yes var negativeButtonId: Int = R.string.no - if (recipient.isGroupRecipient) { + if (recipient.isGroupOrCommunityRecipient) { val group = groupDatabase.getGroup(recipient.address.toString()).orNull() // If you are an admin of this group you can delete it @@ -640,31 +644,29 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity // 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())) { - try { - GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() - .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) - ?.let { MessageSender.explicitLeave(it, false) } - } catch (ioe: IOException) { - Log.w(TAG, "Got an IOException while sending leave group message") - } - } + sessionJobDatabase + .cancelPendingMessageSendJobs(threadID) + // Delete the conversation - val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) - if (v2OpenGroup != null) { - v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) } + val community = lokiThreadDatabase + .getOpenGroupChat(threadID) + if (community != null) { + OpenGroupManager.delete( + community.server, + community.room, + context + ) } else { - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.Default) { threadDb.deleteConversation(threadID) } } + // Update the badge count ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) // Notify the user - val toastMessage = if (recipient.isGroupRecipient) R.string.groupMemberYouLeft else R.string.conversationsDeleted + val toastMessage = if (recipient.isGroupOrCommunityRecipient) R.string.groupMemberYouLeft else R.string.conversationsDeleted Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index 89f02ee21aa..33471195ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -46,7 +46,7 @@ class HomeDiffUtil( oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending && oldItem.lastSeen == newItem.lastSeen && - configFactory.convoVolatile?.getConversationUnread(newItem) != true && + !configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(newItem) } && old.typingThreadIDs.contains(oldItem.threadId) == new.typingThreadIDs.contains(newItem.threadId) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index dd6d24cd00d..884df78c9a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -80,7 +80,7 @@ class HomeViewModel @Inject constructor( ).flowOn(Dispatchers.IO) private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() - .map { threadDb.unapprovedConversationCount } + .map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } } private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges() .map { threadDb.latestUnapprovedConversationTimestamp } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index d5fad90c4a1..c21f7d37e83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -59,7 +59,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { profilePictureView.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { - if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener + if (recipient.isCommunityInboxRecipient || recipient.isCommunityOutboxRecipient) return@setOnClickListener nameTextViewContainer.visibility = View.INVISIBLE nameEditTextContainer.visibility = View.VISIBLE nicknameEditText.text = null @@ -87,12 +87,12 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameEditIcon.isVisible = threadRecipient.isContactRecipient - && !threadRecipient.isOpenGroupInboxRecipient - && !threadRecipient.isOpenGroupOutboxRecipient + && !threadRecipient.isCommunityInboxRecipient + && !threadRecipient.isCommunityOutboxRecipient publicKeyTextView.isVisible = !threadRecipient.isCommunityRecipient - && !threadRecipient.isOpenGroupInboxRecipient - && !threadRecipient.isOpenGroupOutboxRecipient + && !threadRecipient.isCommunityInboxRecipient + && !threadRecipient.isCommunityOutboxRecipient messageButton.isVisible = !threadRecipient.isCommunityRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true publicKeyTextView.text = publicKey publicKeyTextView.setOnLongClickListener { 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 71c2c625068..bc9d597641f 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 @@ -11,7 +11,6 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString import java.security.InvalidParameterException @@ -136,8 +135,8 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie constructor(title: String): this(GetString(title)) } data class SavedMessages(val currentUserPublicKey: String): Model() - data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model() - data class GroupConversation(val groupRecord: GroupRecord): Model() - data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model() + data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model() + data class GroupConversation(val groupRecord: GroupRecord) : Model() + data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : 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 947edc3d8e1..58afdae7b6c 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 @@ -89,7 +89,7 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { fun ContentView.bindModel(query: String?, model: GroupConversation) { binding.searchResultProfilePicture.isVisible = true - binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup + binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) binding.searchResultProfilePicture.update(threadRecipient) @@ -99,7 +99,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName) - if (model.groupRecord.isClosedGroup) { + if (model.groupRecord.isLegacyGroup) { binding.searchResultSubtitle.text = getHighlight(query, membersString) } } @@ -127,13 +127,6 @@ fun ContentView.bindModel(model: SavedMessages) { fun ContentView.bindModel(query: String?, model: Message) = binding.apply { searchResultProfilePicture.isVisible = true searchResultTimestamp.isVisible = true - -// val hasUnreads = model.unread > 0 -// unreadCountIndicator.isVisible = hasUnreads -// if (hasUnreads) { -// unreadCountTextView.text = model.unread.toString() -// } - searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index b856745e47f..0356dd32382 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -282,7 +282,7 @@ class MediaOverviewViewModel( // in groups/communities) if (selectedMedia.any { !it.mediaRecord.isOutgoing } && successCount > 0 && - !address.isGroup) { + !address.isGroupOrCommunity) { withContext(Dispatchers.Default) { val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index e27345afc75..9f1360815a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityMessageRequestsBinding import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase @@ -80,7 +82,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat override fun onBlockConversationClick(thread: ThreadRecord) { fun doBlock() { - viewModel.blockMessageRequest(thread) + val recipient = thread.invitingAdminId?.let { + Recipient.from(this, Address.fromSerialized(it), false) + } ?: thread.recipient + viewModel.blockMessageRequest(thread, recipient) LoaderManager.getInstance(this).restartLoader(0, null, this) } @@ -100,9 +105,6 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat fun doDecline() { viewModel.deleteMessageRequest(thread) LoaderManager.getInstance(this).restartLoader(0, null, this) - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) - } } showSessionDialog { @@ -123,9 +125,6 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat fun doDeleteAllAndBlock() { viewModel.clearAllMessageRequests(false) LoaderManager.getInstance(this).restartLoader(0, null, this) - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) - } } showSessionDialog { 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 cb352d83b7e..3f6cb482c5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -31,7 +31,9 @@ class MessageRequestsAdapter( val view = MessageRequestView(context) view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } view.setOnLongClickListener { - view.thread?.let { showPopupMenu(view) } + view.thread?.let { thread -> + showPopupMenu(view, thread.recipient.isGroupOrCommunityRecipient, thread.invitingAdminId) + } true } return ViewHolder(view) @@ -47,10 +49,14 @@ class MessageRequestsAdapter( holder?.view?.recycle() } - private fun showPopupMenu(view: MessageRequestView) { + private fun showPopupMenu(view: MessageRequestView, groupRecipient: Boolean, invitingAdmin: String?) { val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) - popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) - popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient + // still show the block option if we have an inviting admin for the group + if ((groupRecipient && invitingAdmin == null) || view.thread!!.recipient.isCommunityInboxRecipient) { + popupMenu.menuInflater.inflate(R.menu.menu_group_request, popupMenu.menu) + } else { + popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) + } popupMenu.setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == R.id.menu_delete_message_request) { listener.onDeleteConversationClick(view.thread!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt index a3a7caf8d2c..d9003d005a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.repository.ConversationRepository import javax.inject.Inject @@ -13,12 +14,10 @@ class MessageRequestsViewModel @Inject constructor( private val repository: ConversationRepository ) : ViewModel() { - fun blockMessageRequest(thread: ThreadRecord) = viewModelScope.launch { - val recipient = thread.recipient - if (recipient.isContactRecipient) { - repository.setBlocked(recipient, true) - deleteMessageRequest(thread) - } + // We assume thread.recipient is a contact or thread.invitingAdmin is not null + fun blockMessageRequest(thread: ThreadRecord, blockRecipient: Recipient) = viewModelScope.launch { + repository.setBlocked(thread.threadId, blockRecipient, true) + deleteMessageRequest(thread) } fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 88f92ecb484..2d22a135325 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -68,7 +68,7 @@ protected Void doInBackground(Void... params) { messageIdsCollection.addAll(messageIds); } - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); + ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context); MarkReadReceiver.process(context, messageIdsCollection); return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 0bfa2b08995..31a368687c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -93,7 +93,7 @@ protected Void doInBackground(Void... params) { long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis(); long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; - if (recipient.isGroupRecipient()) { + if (recipient.isGroupOrCommunityRecipient()) { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { @@ -109,7 +109,7 @@ protected Void doInBackground(Void... params) { List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(replyThreadId, true); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); + ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context); MarkReadReceiver.process(context, messageIds); return null; 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 63f6d07da1e..25ff4c8a102 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -7,20 +7,24 @@ 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 +import kotlinx.coroutines.GlobalScope import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind +import org.session.libsession.database.userAuth 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.snode.utilities.asyncPromise +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.recover @@ -108,20 +112,23 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor var dmsPromise: Promise = Promise.ofSuccess(Unit) if (requestTargets.contains(Targets.DMS)) { - val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes -> + val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) + dmsPromise = SnodeAPI.getMessages(userAuth).bind { envelopes -> val params = envelopes.map { (envelope, serverHash) -> // FIXME: Using a job here seems like a bad idea... MessageReceiveParameters(envelope.toByteArray(), serverHash, null) } - BatchMessageReceiveJob(params).executeAsync("background") + + GlobalScope.asyncPromise { + BatchMessageReceiveJob(params).executeAsync("background") + } } promises.add(dmsPromise) } // 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.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index cbf53e6a8b3..acf98ac0d84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -28,8 +28,6 @@ import android.database.Cursor import android.os.AsyncTask import android.os.Build import android.text.TextUtils -import android.widget.Toast -import androidx.camera.core.impl.utils.ContextUtil.getApplicationContext import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -43,11 +41,9 @@ import kotlin.concurrent.Volatile import me.leolin.shortcutbadger.ShortcutBadger import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ServiceUtil -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy @@ -56,6 +52,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHidde import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Util @@ -70,8 +67,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.SlideDeck -import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.preferences.ShareLogsDialog import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.SessionMetaProtocol.canUserReplyToNotification import org.thoughtcrime.securesms.util.SpanUtil @@ -172,7 +167,7 @@ class DefaultMessageNotifier : MessageNotifier { val threads = get(context).threadDatabase() val recipient = threads.getRecipientForThreadId(threadId) - if (recipient != null && !recipient.isGroupRecipient && threads.getMessageCount(threadId) == 1 && + if (recipient != null && !recipient.isGroupOrCommunityRecipient && threads.getMessageCount(threadId) == 1 && !(recipient.isApproved || threads.getLastSeenAndHasSent(threadId).second()) ) { removeHasHiddenMessageRequests(context) @@ -486,7 +481,7 @@ class DefaultMessageNotifier : MessageNotifier { if (threadId != -1L) { threadRecipients = threadDatabase.getRecipientForThreadId(threadId) - messageRequest = threadRecipients != null && !threadRecipients.isGroupRecipient && + messageRequest = threadRecipients != null && !threadRecipients.isGroupOrCommunityRecipient && !threadRecipients.isApproved && !threadDatabase.getLastSeenAndHasSent(threadId).second() if (messageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !hasHiddenMessageRequests(context))) { continue @@ -559,7 +554,7 @@ class DefaultMessageNotifier : MessageNotifier { .findLast() if (lastReact.isPresent) { - if (threadRecipients != null && !threadRecipients.isGroupRecipient) { + if (threadRecipients != null && !threadRecipients.isGroupOrCommunityRecipient) { val reaction = lastReact.get() val reactor = Recipient.from(context, fromSerialized(reaction.author), false) val emoji = Phrase.from(context, R.string.emojiReactsNotification).put(EMOJI_KEY, reaction.emoji).format().toString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java index 8db5f810b4e..7fb29b9bd76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java @@ -17,7 +17,7 @@ public class DeleteNotificationReceiver extends BroadcastReceiver { @Override public void onReceive(final Context context, Intent intent) { if (DELETE_NOTIFICATION_ACTION.equals(intent.getAction())) { - ApplicationContext.getInstance(context).messageNotifier.clearReminder(context); + ApplicationContext.getInstance(context).getMessageNotifier().clearReminder(context); final long[] ids = intent.getLongArrayExtra(EXTRA_IDS); final boolean[] mms = intent.getBooleanArrayExtra(EXTRA_MMS); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 59681c1f8a5..fee729cf422 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -6,13 +6,17 @@ import android.content.Context import android.content.Intent import android.os.AsyncTask import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender.send import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.SSKEnvironment -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.associateByNotNull import org.session.libsession.utilities.recipients.Recipient @@ -24,6 +28,7 @@ import org.thoughtcrime.securesms.database.MarkedMessageInfo import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt +@AndroidEntryPoint class MarkReadReceiver : BroadcastReceiver() { @SuppressLint("StaticFieldLeak") override fun onReceive(context: Context, intent: Intent) { @@ -68,13 +73,15 @@ class MarkReadReceiver : BroadcastReceiver() { .filter { it.expiryType == ExpiryType.AFTER_READ } .map { it.syncMessageId } .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { - isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupRecipient == true } == false + isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunityRecipient == true } == false } .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) } - hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { - fetchUpdatedExpiriesAndScheduleDeletion(context, it) - shortenExpiryOfDisappearingAfterRead(context, it) + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> + GlobalScope.launch { + fetchUpdatedExpiriesAndScheduleDeletion(context, hashToMessages) + shortenExpiryOfDisappearingAfterRead(hashToMessages) + } } } @@ -91,7 +98,6 @@ class MarkReadReceiver : BroadcastReceiver() { } private fun shortenExpiryOfDisappearingAfterRead( - context: Context, hashToMessage: Map ) { hashToMessage.entries @@ -102,7 +108,7 @@ class MarkReadReceiver : BroadcastReceiver() { SnodeAPI.alterTtl( messageHashes = hashes, newExpiry = nowWithOffset + expiresIn, - publicKey = TextSecurePreferences.getLocalNumber(context)!!, + auth = checkNotNull(shared.storage.userAuth) { "No authorized user" }, shorten = true ) } @@ -125,12 +131,12 @@ class MarkReadReceiver : BroadcastReceiver() { } } - private fun fetchUpdatedExpiriesAndScheduleDeletion( + private suspend fun fetchUpdatedExpiriesAndScheduleDeletion( context: Context, hashToMessage: Map ) { @Suppress("UNCHECKED_CAST") - val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map + val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), shared.storage.userAuth!!).await()["expiries"] as Map hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt index a526f9e8f2a..dc13beed33d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt @@ -40,7 +40,7 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati fun setMostRecentSender(recipient: Recipient, threadRecipient: Recipient) { var displayName = recipient.toShortString() - if (threadRecipient.isGroupRecipient) { + if (threadRecipient.isGroupOrCommunityRecipient) { displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient) } if (privacy.isDisplayContact) { @@ -69,7 +69,7 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati fun addMessageBody(sender: Recipient, threadRecipient: Recipient, body: CharSequence?) { var displayName = sender.toShortString() - if (threadRecipient.isGroupRecipient) { + if (threadRecipient.isGroupOrCommunityRecipient) { displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient) } if (privacy.isDisplayMessage) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index 5ca700cf66f..8bcf43887a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -16,6 +16,8 @@ import java.util.concurrent.TimeUnit; +import kotlin.Unit; + public class OptimizedMessageNotifier implements MessageNotifier { private final MessageNotifier wrapped; private final Debouncer debouncer; @@ -118,7 +120,10 @@ public void updateNotification(@androidx.annotation.NonNull Context context, boo private void performOnBackgroundThreadIfNeeded(Runnable r) { if (Looper.myLooper() == Looper.getMainLooper()) { - ThreadUtils.queue(r); + ThreadUtils.queue(() -> { + r.run(); + return Unit.INSTANCE; + }); } else { r.run(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index e4a0fdc5a04..9cf36cdee80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -10,12 +10,12 @@ import androidx.core.content.ContextCompat.getString import com.goterl.lazysodium.interfaces.AEAD import com.goterl.lazysodium.utils.Key import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import network.loki.messenger.R import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.SodiumUtilities @@ -23,14 +23,21 @@ import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString +import org.session.libsignal.protos.SignalServiceProtos.Envelope +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject private const val TAG = "PushHandler" -class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { +class PushReceiver @Inject constructor( + @ApplicationContext private val context: Context, + private val configFactory: ConfigFactory +) { private val json = Json { ignoreUnknownKeys = true } /** @@ -57,17 +64,55 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) } try { - val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() - val job = BatchMessageReceiveJob(listOf( - MessageReceiveParameters( - data = envelopeAsData, - serverHash = pushData.metadata?.msg_hash - ) - ), null) - JobQueue.shared.add(job) + val params = when { + pushData.metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> { + val groupId = AccountId(requireNotNull(pushData.metadata.account) { + "Received a closed group message push notification without an account ID" + }) + + val envelop = checkNotNull(tryDecryptGroupMessage(groupId, pushData.data)) { + "Unable to decrypt closed group message" + } + + MessageReceiveParameters( + data = envelop.toByteArray(), + serverHash = pushData.metadata.msg_hash, + closedGroup = Destination.ClosedGroup(groupId.hexString) + ) + } + + pushData.metadata?.namespace == 0 || pushData.metadata == null -> { + val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() + MessageReceiveParameters( + data = envelopeAsData, + serverHash = pushData.metadata?.msg_hash + ) + } + + else -> { + Log.w(TAG, "Received a push notification with an unknown namespace: ${pushData.metadata.namespace}") + return + } + } + + JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null)) } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } + + } + + + private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? { + val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) { it.groupKeys.decrypt(data) }) { + "Failed to decrypt group message" + } + + Log.d(TAG, "Successfully decrypted group message from ${sender.hexString}") + return Envelope.parseFrom(envelopBytes) + .toBuilder() + .setSource(sender.hexString) + .build() } private fun sendGenericNotification() { @@ -115,11 +160,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) Log.d(TAG, "decrypt() called") val encKey = getOrCreateNotificationKey() - val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() - val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() + val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES) + val payload = + encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size) val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) ?: error("Failed to decrypt push notification") - val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray() + val contentEndedAt = padded.indexOfLast { it.toInt() != 0 } + val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded val bencoded = Bencode.Decoder(decrypted) val expectedList = (bencoded.decode() as? BencodeList)?.values ?: error("Failed to decode bencoded list from payload") @@ -138,17 +185,15 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) } fun getOrCreateNotificationKey(): Key { - if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) { - // generate the key and store it - val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) - IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) + if (keyHex != null) { + return Key.fromHexString(keyHex) } - return Key.fromHexString( - IdentityKeyUtil.retrieve( - context, - IdentityKeyUtil.NOTIFICATION_KEY - ) - ) + + // generate the key and store it + val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) + IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + return key } data class PushData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt new file mode 100644 index 00000000000..f0de8e929d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -0,0 +1,171 @@ +package org.thoughtcrime.securesms.notifications + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import javax.inject.Inject + +private const val TAG = "PushRegistrationHandler" + +/** + * A class that listens to the config, user's preference, token changes and + * register/unregister push notification accordingly. + * + * This class DOES NOT handle the legacy groups push notification. + */ +class PushRegistrationHandler +@Inject +constructor( + private val pushRegistry: PushRegistryV2, + private val configFactory: ConfigFactory, + private val preferences: TextSecurePreferences, + private val storage: Storage, + private val tokenFetcher: TokenFetcher, +) { + @OptIn(DelicateCoroutinesApi::class) + private val scope: CoroutineScope = GlobalScope + + private var job: Job? = null + + @OptIn(FlowPreview::class) + fun run() { + require(job == null) { "Job is already running" } + + job = scope.launch(Dispatchers.Default) { + combine( + (configFactory.configUpdateNotifications as Flow) + .debounce(500L) + .onStart { emit(Unit) }, + IdentityKeyUtil.CHANGES.onStart { emit(Unit) }, + preferences.pushEnabled, + tokenFetcher.token, + ) { _, _, enabled, token -> + if (!enabled || token.isNullOrEmpty()) { + return@combine emptyMap() + } + + val userAuth = + storage.userAuth ?: return@combine emptyMap() + getGroupSubscriptions( + token = token + ) + mapOf( + SubscriptionKey(userAuth.accountId, token) to Subscription(userAuth, 0) + ) + } + .scan, Pair, Map>?>( + null + ) { acc, current -> + val prev = acc?.second.orEmpty() + prev to current + } + .filterNotNull() + .collect { (prev, current) -> + val addedAccountIds = current.keys - prev.keys + val removedAccountIDs = prev.keys - current.keys + if (addedAccountIds.isNotEmpty()) { + Log.d(TAG, "Adding ${addedAccountIds.size} new subscriptions") + } + + if (removedAccountIDs.isNotEmpty()) { + Log.d(TAG, "Removing ${removedAccountIDs.size} subscriptions") + } + + val deferred = mutableListOf>() + + addedAccountIds.mapTo(deferred) { key -> + val subscription = current.getValue(key) + async { + try { + pushRegistry.register( + token = key.token, + swarmAuth = subscription.auth, + namespaces = listOf(subscription.namespace) + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to register for push notification", e) + } + } + } + + removedAccountIDs.mapTo(deferred) { key -> + val subscription = prev.getValue(key) + async { + try { + pushRegistry.unregister( + token = key.token, + swarmAuth = subscription.auth, + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to unregister for push notification", e) + } + } + } + + deferred.awaitAll() + } + } + } + + private fun getGroupSubscriptions( + token: String + ): Map { + return buildMap { + val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + for (group in groups) { + val adminKey = group.adminKey + if (adminKey != null && adminKey.isNotEmpty()) { + put( + SubscriptionKey(group.groupAccountId, token), + Subscription( + auth = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey), + namespace = Namespace.GROUPS() + ) + ) + continue + } + + val authData = group.authData + if (authData != null && authData.isNotEmpty()) { + val subscription = configFactory.getGroupAuth(group.groupAccountId) + ?.let { + Subscription( + auth = it, + namespace = Namespace.GROUPS() + ) + } + + if (subscription != null) { + put(SubscriptionKey(group.groupAccountId, token), subscription) + } + } + } + } + } + + private data class SubscriptionKey(val accountId: AccountId, val token: String) + private data class Subscription(val auth: SwarmAuth, val namespace: Int) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt deleted file mode 100644 index b0954f2327b..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -import android.content.Context -import com.goterl.lazysodium.utils.KeyPair -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.combine.and -import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.utilities.Device -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace -import org.session.libsignal.utilities.emptyPromise -import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import javax.inject.Inject -import javax.inject.Singleton - -private val TAG = PushRegistry::class.java.name - -@Singleton -class PushRegistry @Inject constructor( - @ApplicationContext private val context: Context, - private val device: Device, - private val tokenManager: TokenManager, - private val pushRegistryV2: PushRegistryV2, - private val prefs: TextSecurePreferences, - private val tokenFetcher: TokenFetcher, -) { - - private var pushRegistrationJob: Job? = null - - fun refresh(force: Boolean): Job { - Log.d(TAG, "refresh() called with: force = $force") - - pushRegistrationJob?.apply { - if (force) cancel() else if (isActive) return MainScope().launch {} - } - - return MainScope().launch(Dispatchers.IO) { - try { - register(tokenFetcher.fetch()).get() - } catch (e: Exception) { - Log.e(TAG, "register failed", e) - } - }.also { pushRegistrationJob = it } - } - - fun register(token: String?): Promise<*, Exception> { - Log.d(TAG, "refresh() called") - - if (token?.isNotEmpty() != true) return emptyPromise() - - prefs.setPushToken(token) - - val userPublicKey = prefs.getLocalNumber() ?: return emptyPromise() - val userEdKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return emptyPromise() - - return when { - prefs.isPushEnabled() -> register(token, userPublicKey, userEdKey) - tokenManager.isRegistered -> unregister(token, userPublicKey, userEdKey) - else -> emptyPromise() - } - } - - /** - * Register for push notifications. - */ - private fun register( - token: String, - publicKey: String, - userEd25519Key: KeyPair, - namespaces: List = listOf(Namespace.DEFAULT) - ): Promise<*, Exception> { - Log.d(TAG, "register() called") - - val v1 = PushRegistryV1.register( - device = device, - token = token, - publicKey = publicKey - ) fail { - Log.e(TAG, "register v1 failed", it) - } - - val v2 = pushRegistryV2.register( - device, token, publicKey, userEd25519Key, namespaces - ) fail { - Log.e(TAG, "register v2 failed", it) - } - - return v1 and v2 success { - Log.d(TAG, "register v1 & v2 success") - tokenManager.register() - } - } - - private fun unregister( - token: String, - userPublicKey: String, - userEdKey: KeyPair - ): Promise<*, Exception> = PushRegistryV1.unregister() and pushRegistryV2.unregister( - device, token, userPublicKey, userEdKey - ) fail { - Log.e(TAG, "unregisterBoth failed", it) - } success { - tokenManager.unregister() - } -} 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 42ae798366b..37246be54b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -1,117 +1,130 @@ package org.thoughtcrime.securesms.notifications -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.interfaces.Sign -import com.goterl.lazysodium.utils.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromStream -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.map -import okhttp3.MediaType +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request -import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.messaging.sending_receiving.notifications.Response import org.session.libsession.messaging.sending_receiving.notifications.Server import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.Version +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace -import org.session.libsignal.utilities.retryIfNeeded +import org.session.libsignal.utilities.retryWithUniformInterval import javax.inject.Inject import javax.inject.Singleton -private val TAG = PushRegistryV2::class.java.name private const val maxRetryCount = 4 @Singleton -class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) { - fun register( - device: Device, +class PushRegistryV2 @Inject constructor( + private val pushReceiver: PushReceiver, + private val device: Device, + private val clock: SnodeClock, + ) { + suspend fun register( token: String, - publicKey: String, - userEd25519Key: KeyPair, + swarmAuth: SwarmAuth, namespaces: List - ): Promise { + ) { val pnKey = pushReceiver.getOrCreateNotificationKey() - val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s - // if we want to support passing namespace list, here is the place to do it - val sigData = "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEd25519Key.secretKey.asBytes) + val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s + val publicKey = swarmAuth.accountId.hexString + val signed = swarmAuth.sign( + "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() + ) val requestParameters = SubscriptionRequest( pubkey = publicKey, - session_ed25519 = userEd25519Key.publicKey.asHexString, - namespaces = listOf(Namespace.DEFAULT), + session_ed25519 = swarmAuth.ed25519PublicKeyHex, + namespaces = namespaces, data = true, // only permit data subscription for now (?) service = device.service, sig_ts = timestamp, - signature = Base64.encodeBytes(signature), service_info = mapOf("token" to token), enc_key = pnKey.asHexString, - ).let(Json::encodeToString) + ).let(Json::encodeToJsonElement).jsonObject + signed + + val response = retryResponseBody( + "subscribe", + Json.encodeToString(requestParameters) + ) - return retryResponseBody("subscribe", requestParameters) success { - Log.d(TAG, "registerV2 success") + check(response.isSuccess()) { + "Error subscribing to push notifications: ${response.message}" } } - fun unregister( - device: Device, + suspend fun unregister( token: String, - userPublicKey: String, - userEdKey: KeyPair - ): Promise { - val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s + swarmAuth: SwarmAuth + ) { + val publicKey = swarmAuth.accountId.hexString + val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s // if we want to support passing namespace list, here is the place to do it - val sigData = "UNSUBSCRIBE${userPublicKey}${timestamp}".encodeToByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEdKey.secretKey.asBytes) + val signature = swarmAuth.signForPushRegistry( + "UNSUBSCRIBE${publicKey}${timestamp}".encodeToByteArray() + ) val requestParameters = UnsubscriptionRequest( - pubkey = userPublicKey, - session_ed25519 = userEdKey.publicKey.asHexString, + pubkey = publicKey, + session_ed25519 = swarmAuth.ed25519PublicKeyHex, service = device.service, sig_ts = timestamp, - signature = Base64.encodeBytes(signature), service_info = mapOf("token" to token), - ).let(Json::encodeToString) + ).let(Json::encodeToJsonElement).jsonObject + signature - return retryResponseBody("unsubscribe", requestParameters) success { - Log.d(TAG, "unregisterV2 success") + val response: UnsubscribeResponse = retryResponseBody("unsubscribe", Json.encodeToString(requestParameters)) + + check(response.isSuccess()) { + "Error unsubscribing to push notifications: ${response.message}" } } - private inline fun retryResponseBody(path: String, requestParameters: String): Promise = - retryIfNeeded(maxRetryCount) { getResponseBody(path, requestParameters) } + private operator fun JsonObject.plus(additional: Map): JsonObject { + return JsonObject(buildMap { + putAll(this@plus) + for ((key, value) in additional) { + put(key, JsonPrimitive(value)) + } + }) + } + + private suspend inline fun retryResponseBody(path: String, requestParameters: String): T = + retryWithUniformInterval(maxRetryCount = maxRetryCount) { getResponseBody(path, requestParameters) } - private inline fun getResponseBody(path: String, requestParameters: String): Promise { + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun getResponseBody(path: String, requestParameters: String): T { val server = Server.LATEST val url = "${server.url}/$path" - val body = RequestBody.create("application/json".toMediaType(), requestParameters) + val body = requestParameters.toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() + val response = OnionRequestAPI.sendOnionRequest( + request = request, + server = server.url, + x25519PublicKey = server.publicKey, + version = Version.V4 + ).await() - return OnionRequestAPI.sendOnionRequest( - request, - server.url, - server.publicKey, - Version.V4 - ).map { response -> - response.body!!.inputStream() - .let { Json.decodeFromStream(it) } - .also { if (it.isFailure()) throw Exception("error: ${it.message}.") } + return withContext(Dispatchers.IO) { + requireNotNull(response.body) { "Response doesn't have a body" } + .inputStream() + .use { Json.decodeFromStream(it) } } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index cf0e04ddf46..c1391b9a8f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -31,11 +31,11 @@ import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.snode.SnodeAPI; +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; +import org.session.libsession.snode.SnodeClock; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; @@ -70,6 +70,10 @@ public class RemoteReplyReceiver extends BroadcastReceiver { SmsDatabase smsDatabase; @Inject Storage storage; + @Inject + MessageNotifier messageNotifier; + @Inject + SnodeClock clock; @SuppressLint("StaticFieldLeak") @Override @@ -94,7 +98,7 @@ protected Void doInBackground(Void... params) { Recipient recipient = Recipient.from(context, address, false); long threadId = threadDatabase.getOrCreateThreadIdFor(recipient); VisibleMessage message = new VisibleMessage(); - message.setSentTimestamp(SnodeAPI.getNowWithOffset()); + message.setSentTimestamp(clock.currentTimeMills()); message.setText(responseText.toString()); ExpirationConfiguration config = storage.getExpirationConfiguration(threadId); ExpiryMode expiryMode = config == null ? null : config.getExpiryMode(); @@ -124,7 +128,7 @@ protected Void doInBackground(Void... params) { List messageIds = threadDatabase.setRead(threadId, true); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); + messageNotifier.updateNotification(context); MarkReadReceiver.process(context, messageIds); return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java index be2ab1d31d2..43b8b1d55f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java @@ -11,7 +11,7 @@ public enum ReplyMethod { SecureMessage; public static @NonNull ReplyMethod forRecipient(Context context, Recipient recipient) { - if (recipient.isGroupRecipient()) { + if (recipient.isGroupOrCommunityRecipient()) { return ReplyMethod.GroupMessage; } return ReplyMethod.SecureMessage; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 6764f460690..0b22de8d3b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -29,7 +29,6 @@ import org.session.libsession.avatars.ContactPhoto; import org.session.libsession.messaging.contacts.Contact; import org.session.libsession.utilities.NotificationPrivacyPreference; -import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; @@ -119,7 +118,7 @@ public void setPrimaryMessageBody(@NonNull Recipient threadRecipient, { SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); - if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { + if (privacy.isDisplayContact() && threadRecipient.isGroupOrCommunityRecipient()) { String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } @@ -207,7 +206,7 @@ public void addMessageBody(@NonNull Recipient threadRecipient, { SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); - if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { + if (privacy.isDisplayContact() && threadRecipient.isGroupOrCommunityRecipient()) { String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt deleted file mode 100644 index 5bd9ce0d8d7..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -interface TokenFetcher { - suspend fun fetch(): String? -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt deleted file mode 100644 index b3db642b812..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import org.session.libsession.utilities.TextSecurePreferences -import javax.inject.Inject -import javax.inject.Singleton - -private const val INTERVAL: Int = 12 * 60 * 60 * 1000 - -@Singleton -class TokenManager @Inject constructor( - @ApplicationContext private val context: Context, -) { - val hasValidRegistration get() = isRegistered && !isExpired - val isRegistered get() = time > 0 - private val isExpired get() = currentTime() > time + INTERVAL - - fun register() { - time = currentTime() - } - - fun unregister() { - time = 0 - } - - private var time - get() = TextSecurePreferences.getPushRegisterTime(context) - set(value) = TextSecurePreferences.setPushRegisterTime(context, value) - - private fun currentTime() = System.currentTimeMillis() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index c87d5fc568f..57cfb1dcab1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -27,13 +27,14 @@ import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) -@OptIn(ExperimentalFoundationApi::class) @Composable internal fun LoadAccountScreen( state: State, @@ -97,10 +98,11 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo style = LocalType.current.base ) Spacer(Modifier.height(LocalDimensions.current.spacing)) + SessionOutlinedTextField( text = state.recoveryPhrase, - modifier = Modifier.fillMaxWidth(), - contentDescription = stringResource(R.string.AccessibilityId_recoveryPasswordEnter), + modifier = Modifier.fillMaxWidth() + .qaTag(stringResource(R.string.AccessibilityId_recoveryPasswordEnter)), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, onContinue = onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt index abf0471598e..ecb923ca105 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt @@ -28,8 +28,6 @@ class LoadingActivity: BaseActionBarActivity() { private val viewModel: LoadingViewModel by viewModels() private fun register(loadFailed: Boolean) { - prefs.setLastConfigurationSyncTime(System.currentTimeMillis()) - when { loadFailed -> startPickDisplayNameActivity(loadFailed = true) else -> startHomeActivity(isNewAccount = false, isFromOnboarding = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt index a7871d5620e..f5aed46bb98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt @@ -16,12 +16,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.TextSecurePreferences import javax.inject.Inject import kotlin.time.Duration @@ -43,7 +48,8 @@ private val REFRESH_TIME = 50.milliseconds @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @HiltViewModel internal class LoadingViewModel @Inject constructor( - val prefs: TextSecurePreferences + val prefs: TextSecurePreferences, + val configFactory: ConfigFactoryProtocol, ): ViewModel() { private val state = MutableStateFlow(State.LOADING) @@ -55,7 +61,7 @@ internal class LoadingViewModel @Inject constructor( val events = _events.asSharedFlow() init { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { state.flatMapLatest { when (it) { State.LOADING -> progress(0f, 1f, TIMEOUT_TIME) @@ -65,14 +71,19 @@ internal class LoadingViewModel @Inject constructor( .collectLatest { _progress.value = it } } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { try { - TextSecurePreferences.events - .filter { it == TextSecurePreferences.CONFIGURATION_SYNCED } - .onStart { emit(TextSecurePreferences.CONFIGURATION_SYNCED) } - .filter { prefs.getConfigurationMessageSynced() } + configFactory.configUpdateNotifications + .filterIsInstance() + .onStart { emit(ConfigUpdateNotification.UserConfigsModified) } + .filter { + configFactory.withUserConfigs { configs -> + !configs.userProfile.getName().isNullOrEmpty() + } + } .timeout(TIMEOUT_TIME) - .collectLatest { onSuccess() } + .first() + onSuccess() } catch (e: Exception) { onFail() } @@ -80,19 +91,15 @@ internal class LoadingViewModel @Inject constructor( } private suspend fun onSuccess() { - withContext(Dispatchers.Main) { - state.value = State.SUCCESS - delay(IDLE_DONE_TIME) - _events.emit(Event.SUCCESS) - } - } + state.value = State.SUCCESS + delay(IDLE_DONE_TIME) + _events.emit(Event.SUCCESS) +} private suspend fun onFail() { - withContext(Dispatchers.Main) { - state.value = State.FAIL - delay(IDLE_DONE_TIME) - _events.emit(Event.TIMEOUT) - } + state.value = State.FAIL + delay(IDLE_DONE_TIME) + _events.emit(Event.TIMEOUT) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt index 40bf5772553..d6f8c99256d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -43,9 +43,9 @@ class CreateAccountManager @Inject constructor( prefs.setLocalNumber(userHexEncodedPublicKey) prefs.setRestorationTime(0) - // we'll rely on the config syncing in the homeActivity resume - configFactory.keyPairChanged() - configFactory.user?.setName(displayName) + configFactory.withMutableUserConfigs { + it.userProfile.setName(displayName) + } versionDataFetcher.startTimedVersionCheck() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt index 51d1b24609c..ad56c939224 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -19,7 +19,6 @@ import javax.inject.Singleton @Singleton class LoadAccountManager @Inject constructor( @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, - private val configFactory: ConfigFactory, private val prefs: TextSecurePreferences, private val versionDataFetcher: VersionDataFetcher ) { @@ -44,7 +43,6 @@ class LoadAccountManager @Inject constructor( val keyPairGenerationResult = KeyPairUtilities.generate(seed) val x25519KeyPair = keyPairGenerationResult.x25519KeyPair KeyPairUtilities.store(context, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) - configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val registrationID = org.session.libsignal.utilities.KeyHelper.generateRegistrationId(false) prefs.apply { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt index a48f0472035..0508e97946b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt @@ -17,14 +17,12 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.onboarding.manager.CreateAccountManager internal class MessageNotificationsViewModel( private val state: State, private val application: Application, private val prefs: TextSecurePreferences, - private val pushRegistry: PushRegistry, private val createAccountManager: CreateAccountManager ): AndroidViewModel(application) { private val _uiStates = MutableStateFlow(UiState()) @@ -42,7 +40,6 @@ internal class MessageNotificationsViewModel( if (state is State.CreateAccount) createAccountManager.createAccount(state.displayName) prefs.setPushEnabled(uiStates.value.pushEnabled) - pushRegistry.refresh(true) _events.emit( when (state) { @@ -102,7 +99,6 @@ internal class MessageNotificationsViewModel( @Assisted private val profileName: String?, private val application: Application, private val prefs: TextSecurePreferences, - private val pushRegistry: PushRegistry, private val createAccountManager: CreateAccountManager, ) : ViewModelProvider.Factory { @@ -111,7 +107,6 @@ internal class MessageNotificationsViewModel( state = profileName?.let(State::CreateAccount) ?: State.LoadAccount, application = application, prefs = prefs, - pushRegistry = pushRegistry, createAccountManager = createAccountManager ) as T } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt index 1481695a3a5..bdb66346e02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -20,6 +20,8 @@ import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalType @Preview @@ -63,10 +65,10 @@ internal fun PickDisplayName( style = LocalType.current.base, modifier = Modifier.padding(bottom = LocalDimensions.current.xsSpacing)) Spacer(Modifier.height(LocalDimensions.current.spacing)) + SessionOutlinedTextField( text = state.displayName, - modifier = Modifier.fillMaxWidth(), - contentDescription = stringResource(R.string.AccessibilityId_displayNameEnter), + modifier = Modifier.fillMaxWidth().qaTag(stringResource(R.string.AccessibilityId_displayNameEnter)), placeholder = stringResource(R.string.displayNameEnter), onChange = onChange, onContinue = onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt index d17c6f602c5..9bb14d41d33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt @@ -49,9 +49,9 @@ internal class PickDisplayNameViewModel( viewModelScope.launch(Dispatchers.IO) { if (loadFailed) { prefs.setProfileName(displayName) - // we'll rely on the config syncing in the homeActivity resume - configFactory.user?.setName(displayName) - + configFactory.withMutableUserConfigs { + it.userProfile.setName(displayName) + } _events.emit(Event.LoadAccountComplete) } else _events.emit(Event.CreateAccount(displayName)) } 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 ae9dfe47608..612cb69af7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +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 98ad62dcb3f..4600c0a4a66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -8,7 +8,7 @@ import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job @@ -16,6 +16,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.DialogClearAllDataBinding +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log @@ -23,12 +25,17 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject +@AndroidEntryPoint class ClearAllDataDialog : DialogFragment() { private val TAG = "ClearAllDataDialog" private lateinit var binding: DialogClearAllDataBinding + @Inject + lateinit var storage: StorageProtocol + private enum class Steps { INFO_PROMPT, NETWORK_PROMPT, @@ -115,15 +122,6 @@ class ClearAllDataDialog : DialogFragment() { } private suspend fun performDeleteLocalDataOnlyStep() { - try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() - } catch (e: Exception) { - Log.e(TAG, "Failed to force sync when deleting data", e) - withContext(Main) { - Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show() - } - return - } ApplicationContext.getInstance(context).clearAllDataAndRestart().let { success -> withContext(Main) { if (success) { @@ -149,7 +147,7 @@ class ClearAllDataDialog : DialogFragment() { openGroups.map { it.value.server }.toSet().forEach { server -> OpenGroupApi.deleteAllInboxMessages(server).get() } - SnodeAPI.deleteAllMessages().get() + SnodeAPI.deleteAllMessages(checkNotNull(storage.userAuth)).get() } catch (e: Exception) { Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) null diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt index af282d49724..d8aed33e2c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt @@ -9,27 +9,20 @@ import android.os.AsyncTask import android.os.Bundle import android.provider.Settings import android.text.TextUtils -import androidx.lifecycle.lifecycleScope +import androidx.preference.ListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.notifications.NotificationChannels -import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.preferences.widgets.DropDownPreference import java.util.Arrays import javax.inject.Inject @AndroidEntryPoint class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { - @Inject - lateinit var pushRegistry: PushRegistry - @Inject lateinit var prefs: TextSecurePreferences @@ -39,24 +32,12 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { // Set up FCM toggle val fcmKey = "pref_key_use_fcm" val fcmPreference: SwitchPreferenceCompat = findPreference(fcmKey)!! - fcmPreference.isChecked = prefs.isPushEnabled() + fcmPreference.isChecked = prefs.pushEnabled.value fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any -> - prefs.setPushEnabled(newValue as Boolean) - val job = pushRegistry.refresh(true) - - fcmPreference.isEnabled = false - - lifecycleScope.launch(Dispatchers.IO) { - job.join() - - withContext(Dispatchers.Main) { - fcmPreference.isEnabled = true - } + prefs.setPushEnabled(newValue as Boolean) + true } - true - } - prefs.setNotificationRingtone( NotificationChannels.getMessageRingtone(requireContext()).toString() ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index a2170e2cf73..8b163c43fb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -38,31 +38,32 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)!! .onPreferenceChangeListener = CallToggleListener(this) { setCall(it) } findPreference(getString(R.string.sessionMessageRequests))?.let { category -> - when (val user = configFactory.user) { - null -> category.isVisible = false - else -> SwitchPreferenceCompat(requireContext()).apply { - key = TextSecurePreferences.ALLOW_MESSAGE_REQUESTS - preferenceDataStore = object : PreferenceDataStore() { + SwitchPreferenceCompat(requireContext()).apply { + key = TextSecurePreferences.ALLOW_MESSAGE_REQUESTS + preferenceDataStore = object : PreferenceDataStore() { - override fun getBoolean(key: String?, defValue: Boolean): Boolean { - if (key == TextSecurePreferences.ALLOW_MESSAGE_REQUESTS) { - return user.getCommunityMessageRequests() + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + if (key == TextSecurePreferences.ALLOW_MESSAGE_REQUESTS) { + return configFactory.withMutableUserConfigs { + it.userProfile.getCommunityMessageRequests() } - return super.getBoolean(key, defValue) } + return super.getBoolean(key, defValue) + } - override fun putBoolean(key: String?, value: Boolean) { - if (key == TextSecurePreferences.ALLOW_MESSAGE_REQUESTS) { - user.setCommunityMessageRequests(value) - return + override fun putBoolean(key: String?, value: Boolean) { + if (key == TextSecurePreferences.ALLOW_MESSAGE_REQUESTS) { + configFactory.withMutableUserConfigs { + it.userProfile.setCommunityMessageRequests(value) } - super.putBoolean(key, value) + return } + super.putBoolean(key, value) } - title = getString(R.string.messageRequestsCommunities) - summary = getString(R.string.messageRequestsCommunitiesDescription) - }.let(category::addPreference) - } + } + title = getString(R.string.messageRequestsCommunities) + summary = getString(R.string.messageRequestsCommunitiesDescription) + }.let(category::addPreference) } initializeVisibility() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index c3ecc3cdac1..6d4e2486f4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -312,16 +312,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } else { // if we have a network connection then attempt to update the display name TextSecurePreferences.setProfileName(this, displayName) - val user = viewModel.getUser() - if (user == null) { - Log.w(TAG, "Cannot update display name - missing user details from configFactory.") - } else { - user.setName(displayName) - // sync remote config - ConfigurationMessageUtilities.syncConfigurationIfNeeded(this) - binding.btnGroupNameDisplay.text = displayName - updateWasSuccessful = true - } + viewModel.updateName(displayName) + binding.btnGroupNameDisplay.text = displayName + updateWasSuccessful = true } // Inform the user if we failed to update the display name diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 07240e24043..a1f724ec2dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -21,7 +21,6 @@ import network.loki.messenger.R import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.ProfilePictureUtilities @@ -37,7 +36,6 @@ import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogStat import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints import org.thoughtcrime.securesms.util.BitmapDecodingException import org.thoughtcrime.securesms.util.BitmapUtil -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.NetworkUtils import java.io.File import java.io.IOException @@ -114,8 +112,6 @@ class SettingsViewModel @Inject constructor( fun getTempFile() = tempFile - fun getUser() = configFactory.user - fun onAvatarPicked(result: CropImageView.CropResult) { when { result.isSuccessful -> { @@ -204,7 +200,6 @@ class SettingsViewModel @Inject constructor( ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, context) // If the online portion of the update succeeded then update the local state - val userConfig = configFactory.user AvatarHelper.setAvatar( context, Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)!!), @@ -227,18 +222,15 @@ class SettingsViewModel @Inject constructor( // If we have a URL and a profile key then set the user's profile picture if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { - userConfig?.setPic(UserPic(url, profileKey)) + configFactory.withMutableUserConfigs { + it.userProfile.setPic(UserPic(url, profileKey)) + } } // update dialog state _avatarDialogState.value = AvatarDialogState.UserAvatar(userAddress) } - if (userConfig != null && userConfig.needsDump()) { - configFactory.persist(userConfig, SnodeAPI.nowWithOffset) - } - - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } catch (e: Exception){ // If the sync failed then inform the user Log.d(TAG, "Error syncing avatar: $e") withContext(Dispatchers.Main) { @@ -253,6 +245,12 @@ class SettingsViewModel @Inject constructor( } } + fun updateName(displayName: String) { + configFactory.withMutableUserConfigs { + it.userProfile.setName(displayName) + } + } + fun permanentlyHidePassword() { //todo we can simplify this once we expose all our sharedPrefs as flows prefs.setHidePassword(true) 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 37bd7e4695e..dd9f17281bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -9,6 +9,8 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; + +import org.session.libsignal.utilities.AccountId; import java.util.Collections; import java.util.List; import network.loki.messenger.R; 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 4695e21825a..ff9d25565a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -5,10 +5,14 @@ import android.content.Context import app.cash.copper.Query import app.cash.copper.flow.observeQuery import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -19,19 +23,19 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase -import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SessionJobDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.Storage @@ -39,10 +43,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? @@ -53,11 +54,12 @@ interface ConversationRepository { fun getDraft(threadId: Long): String? fun clearDrafts(threadId: Long) fun inviteContacts(threadId: Long, contacts: List) - fun setBlocked(recipient: Recipient, blocked: Boolean) + fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) fun markAsDeletedLocally(messages: Set, displayedMessage: String) fun deleteMessages(messages: Set, threadId: Long) fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) + fun isGroupReadOnly(recipient: Recipient): Boolean suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set) suspend fun delete1on1MessagesRemotely( @@ -75,16 +77,17 @@ interface ConversationRepository { messages: Set ) - fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? + suspend fun deleteGroupV2MessagesRemotely(recipient: Recipient, messages: Set) + suspend fun banUser(threadId: Long, recipient: Recipient): Result suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result suspend fun deleteThread(threadId: Long): Result suspend fun deleteMessageRequest(thread: ThreadRecord): Result suspend fun clearAllMessageRequests(block: Boolean): Result suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result - - fun declineMessageRequest(threadId: Long) + suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result fun hasReceived(threadId: Long): Boolean + fun getInvitingAdmin(threadId: Long): Recipient? } class DefaultConversationRepository @Inject constructor( @@ -97,13 +100,13 @@ class DefaultConversationRepository @Inject constructor( private val smsDb: SmsDatabase, private val mmsDb: MmsDatabase, private val mmsSmsDb: MmsSmsDatabase, - private val recipientDb: RecipientDatabase, private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, private val sessionJobDb: SessionJobDatabase, - private val configDb: ExpirationConfigurationDatabase, private val configFactory: ConfigFactory, private val contentResolver: ContentResolver, + private val groupManager: GroupManagerV2, + private val clock: SnodeClock, ) : ConversationRepository { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { @@ -111,7 +114,7 @@ class DefaultConversationRepository @Inject constructor( } override fun maybeGetBlindedRecipient(recipient: Recipient): Recipient? { - if (!recipient.isOpenGroupInboxRecipient) return null + if (!recipient.isCommunityInboxRecipient) return null return Recipient.from( context, Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxAccountId(recipient.address.serialize())), @@ -148,13 +151,13 @@ class DefaultConversationRepository @Inject constructor( val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return for (contact in contacts) { val message = VisibleMessage() - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = clock.currentTimeMills() val openGroupInvitation = OpenGroupInvitation().apply { name = openGroup.name url = openGroup.joinURL } message.openGroupInvitation = openGroupInvitation - val expirationConfig = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(contact).let(storage::getExpirationConfiguration) + val expirationConfig = threadDb.getOrCreateThreadIdFor(contact).let(storage::getExpirationConfiguration) val expiresInMillis = expirationConfig?.expiryMode?.expiryMillis ?: 0 val expireStartedAt = if (expirationConfig?.expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( @@ -169,9 +172,23 @@ class DefaultConversationRepository @Inject constructor( } } + override fun isGroupReadOnly(recipient: Recipient): Boolean { + // We only care about group v2 recipient + if (!recipient.isGroupV2Recipient) { + return false + } + + val groupId = recipient.address.serialize() + return configFactory.withUserConfigs { + it.userGroups.getClosedGroup(groupId)?.kicked == true + } || configFactory.withGroupConfigs(AccountId(groupId)) { it.groupInfo.isDestroyed() } + } + // This assumes that recipient.isContactRecipient is true - override fun setBlocked(recipient: Recipient, blocked: Boolean) { - storage.setBlocked(listOf(recipient), blocked) + override fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) { + if (recipient.isContactRecipient) { + storage.setBlocked(listOf(recipient), blocked) + } } /** @@ -261,21 +278,24 @@ class DefaultConversationRepository @Inject constructor( // delete the messages remotely val publicKey = recipient.address.serialize() val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } + val userAuth = requireNotNull(storage.userAuth) { + "User auth is required to delete messages remotely" + } messages.forEach { message -> // delete from swarm messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?.let { serverHash -> - SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) + SnodeAPI.deleteMessage(publicKey, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm - buildUnsendRequest(recipient, message)?.let { unsendRequest -> + buildUnsendRequest(message).let { unsendRequest -> userAddress?.let { MessageSender.send(unsendRequest, it) } } // send an UnsendRequest to recipient's swarm - buildUnsendRequest(recipient, message)?.let { unsendRequest -> + buildUnsendRequest(message).let { unsendRequest -> MessageSender.send(unsendRequest, recipient.address) } } @@ -285,18 +305,32 @@ class DefaultConversationRepository @Inject constructor( recipient: Recipient, messages: Set ) { - if (recipient.isClosedGroupRecipient) { + if (recipient.isLegacyGroupRecipient) { val publicKey = recipient.address messages.forEach { message -> // send an UnsendRequest to group's swarm - buildUnsendRequest(recipient, message)?.let { unsendRequest -> + buildUnsendRequest(message).let { unsendRequest -> MessageSender.send(unsendRequest, publicKey) } } } } + override suspend fun deleteGroupV2MessagesRemotely( + recipient: Recipient, + messages: Set + ) { + require(recipient.isGroupV2Recipient) { "Recipient is not a group v2 recipient" } + + val groupId = AccountId(recipient.address.serialize()) + val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> + messageDataProvider.getServerHashForMessage(msg.id, msg.isMms) + } + + groupManager.requestMessageDeletion(groupId, hashes) + } + override suspend fun deleteNoteToSelfMessagesRemotely( threadId: Long, recipient: Recipient, @@ -305,96 +339,109 @@ class DefaultConversationRepository @Inject constructor( // delete the messages remotely val publicKey = recipient.address.serialize() val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } + val userAuth = requireNotNull(storage.userAuth) { + "User auth is required to delete messages remotely" + } messages.forEach { message -> // delete from swarm messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?.let { serverHash -> - SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) + SnodeAPI.deleteMessage(publicKey, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm - buildUnsendRequest(recipient, message)?.let { unsendRequest -> + buildUnsendRequest(message).let { unsendRequest -> userAddress?.let { MessageSender.send(unsendRequest, it) } } } } - override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { - if (recipient.isCommunityRecipient) return null - messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null + private fun shouldSendUnsendRequest(recipient: Recipient): Boolean { + return recipient.is1on1 || recipient.isLegacyGroupRecipient + } + + private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { return UnsendRequest( author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), timestamp = message.timestamp ) } - override suspend fun banUser(threadId: Long, recipient: Recipient): Result = - suspendCoroutine { continuation -> - val accountID = recipient.address.toString() - val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupApi.ban(accountID, openGroup.room, openGroup.server) - .success { - continuation.resume(Result.success(Unit)) - }.fail { error -> - continuation.resume(Result.failure(error)) - } - } + override suspend fun banUser(threadId: Long, recipient: Recipient): Result = runCatching { + val accountID = recipient.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! + OpenGroupApi.ban(accountID, openGroup.room, openGroup.server).await() + } - override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result = - suspendCoroutine { continuation -> - // Note: This accountId could be the blinded Id - val accountID = recipient.address.toString() - val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - - OpenGroupApi.banAndDeleteAll(accountID, openGroup.room, openGroup.server) - .success { - continuation.resume(Result.success(Unit)) - }.fail { error -> - continuation.resume(Result.failure(error)) - } - } + override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient) = runCatching { + // Note: This accountId could be the blinded Id + val accountID = recipient.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - override suspend fun deleteThread(threadId: Long): Result { - sessionJobDb.cancelPendingMessageSendJobs(threadId) - storage.deleteConversation(threadId) - return Result.success(Unit) + OpenGroupApi.banAndDeleteAll(accountID, openGroup.room, openGroup.server).await() } - override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { - sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - storage.deleteConversation(thread.threadId) - return Result.success(Unit) + override suspend fun deleteThread(threadId: Long) = runCatching { + withContext(Dispatchers.Default) { + sessionJobDb.cancelPendingMessageSendJobs(threadId) + storage.deleteConversation(threadId) + } } - override suspend fun clearAllMessageRequests(block: Boolean): Result { - threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> - while (reader.next != null) { - deleteMessageRequest(reader.current) - val recipient = reader.current.recipient - if (block) { setBlocked(recipient, true) } + override suspend fun deleteMessageRequest(thread: ThreadRecord) + = declineMessageRequest(thread.threadId, thread.recipient) + + override suspend fun clearAllMessageRequests(block: Boolean) = runCatching { + withContext(Dispatchers.Default) { + threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> + while (reader.next != null) { + deleteMessageRequest(reader.current) + val recipient = reader.current.recipient + if (block && !recipient.isGroupV2Recipient) { + setBlocked(reader.current.threadId, recipient, true) + } + } } } - return Result.success(Unit) } - override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result = 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) + override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient) = runCatching { + withContext(Dispatchers.Default) { + storage.setRecipientApproved(recipient, true) + if (recipient.isGroupV2Recipient) { + groupManager.respondToInvitation( + AccountId(recipient.address.serialize()), + approved = true + ) + } else { + val message = MessageRequestResponse(true) + MessageSender.send( + message = message, + destination = Destination.from(recipient.address), + isSyncMessage = recipient.isLocalNumber + ).await() + // add a control message for our user storage.insertMessageRequestResponseFromYou(threadId) - continuation.resume(Result.success(Unit)) - }.fail { error -> - continuation.resume(Result.failure(error)) } + + threadDb.setHasSent(threadId, true) + } } - override fun declineMessageRequest(threadId: Long) { - sessionJobDb.cancelPendingMessageSendJobs(threadId) - storage.deleteConversation(threadId) + override suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result = runCatching { + withContext(Dispatchers.Default) { + sessionJobDb.cancelPendingMessageSendJobs(threadId) + if (recipient.isGroupV2Recipient) { + groupManager.respondToInvitation( + AccountId(recipient.address.serialize()), + approved = false + ) + } else { + storage.deleteConversation(threadId) + } + } } override fun hasReceived(threadId: Long): Boolean { @@ -407,4 +454,10 @@ class DefaultConversationRepository @Inject constructor( return false } + // Only call this with a closed group thread ID + override fun getInvitingAdmin(threadId: Long): Recipient? { + return lokiMessageDb.groupInviteReferrer(threadId)?.let { id -> + Recipient.from(context, Address.fromSerialized(id), false) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 2f6ad7fd8b6..207bb2d30aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -1,46 +1,54 @@ package org.thoughtcrime.securesms.service import android.content.Context +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend -import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID -import org.session.libsession.utilities.GroupUtil.getDecodedGroupIDAsData import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol -import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.mms.MmsException import java.io.IOException import java.util.TreeSet import java.util.concurrent.Executor import java.util.concurrent.Executors +import javax.inject.Inject +import javax.inject.Singleton private val TAG = ExpiringMessageManager::class.java.simpleName -class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtocol { + +@Singleton +class ExpiringMessageManager @Inject constructor( + @ApplicationContext private val context: Context, + private val smsDatabase: SmsDatabase, + private val mmsDatabase: MmsDatabase, + private val mmsSmsDatabase: MmsSmsDatabase, + private val clock: SnodeClock, + private val storage: Lazy, + private val preferences: TextSecurePreferences, +) : MessageExpirationManagerProtocol { private val expiringMessageReferences = TreeSet() private val executor: Executor = Executors.newSingleThreadExecutor() - private val smsDatabase: SmsDatabase - private val mmsDatabase: MmsDatabase - private val mmsSmsDatabase: MmsSmsDatabase - private val context: Context init { - this.context = context.applicationContext - smsDatabase = get(context).smsDatabase() - mmsDatabase = get(context).mmsDatabase() - mmsSmsDatabase = get(context).mmsSmsDatabase() executor.execute(LoadTask()) executor.execute(ProcessTask()) } @@ -77,17 +85,21 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco if (recipient.isBlocked && groupId == null) return try { if (groupId != null) { - val groupID = doubleEncodeGroupID(groupId) - groupInfo = Optional.of( - SignalServiceGroup( - getDecodedGroupIDAsData(groupID), - SignalServiceGroup.GroupType.SIGNAL - ) - ) - val groupAddress = fromSerialized(groupID) + val groupAddress: Address + groupInfo = when { + groupId.startsWith(IdPrefix.GROUP.value) -> { + groupAddress = fromSerialized(groupId) + Optional.of(SignalServiceGroup(Hex.fromStringCondensed(groupId), SignalServiceGroup.GroupType.SIGNAL)) + } + else -> { + val doubleEncoded = GroupUtil.doubleEncodeGroupID(groupId) + groupAddress = fromSerialized(doubleEncoded) + Optional.of(SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(doubleEncoded), SignalServiceGroup.GroupType.SIGNAL)) + } + } recipient = Recipient.from(context, groupAddress, false) } - val threadId = shared.storage.getThreadId(recipient) ?: return + val threadId = storage.get().getThreadId(recipient) ?: return val mediaMessage = IncomingMediaMessage( address, sentTimestamp!!, -1, expiresInMillis, expireStartedAt, true, @@ -119,13 +131,15 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco val groupId = message.groupPublicKey val duration = message.expiryMode.expiryMillis try { - val serializedAddress = groupId?.let(::doubleEncodeGroupID) - ?: message.syncTarget?.takeIf { it.isNotEmpty() } - ?: message.recipient!! + val serializedAddress = when { + groupId == null -> message.syncTarget ?: message.recipient!! + groupId.startsWith(IdPrefix.GROUP.value) -> groupId + else -> doubleEncodeGroupID(groupId) + } val address = fromSerialized(serializedAddress) val recipient = Recipient.from(context, address, false) - message.threadID = shared.storage.getOrCreateThreadIdFor(address) + message.threadID = storage.get().getOrCreateThreadIdFor(address) val timerUpdateMessage = OutgoingExpirationUpdateMessage( recipient, sentTimestamp!!, @@ -149,7 +163,7 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco override fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) { val expiryMode: ExpiryMode = message.expiryMode - val userPublicKey = getLocalNumber(context) + val userPublicKey = preferences.getLocalNumber() val senderPublicKey = message.sender val sentTimestamp = message.sentTimestamp ?: 0 val expireStartedAt = if ((expiryMode is AfterSend || message.isSenderSelf) && !message.isGroup) sentTimestamp else 0 @@ -197,7 +211,7 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco try { while (expiringMessageReferences.isEmpty()) (expiringMessageReferences as Object).wait() val nextReference = expiringMessageReferences.first() - val waitTime = nextReference.expiresAtMillis - nowWithOffset + val waitTime = nextReference.expiresAtMillis - clock.currentTimeMills() if (waitTime > 0) { ExpirationListener.setAlarm(context, waitTime) (expiringMessageReferences as Object).wait(waitTime) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java index 9d95ac5d49a..a3f9d0d97f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -127,7 +127,7 @@ public void setMasterSecret(final Object masterSecret) { @Override protected Void doInBackground(Void... params) { if (!DatabaseUpgradeActivity.isUpdate(KeyCachingService.this)) { - ApplicationContext.getInstance(KeyCachingService.this).messageNotifier.updateNotification(KeyCachingService.this); + ApplicationContext.getInstance(KeyCachingService.this).getMessageNotifier().updateNotification(KeyCachingService.this); } return null; } @@ -193,7 +193,7 @@ private void handleClearKey() { new AsyncTask() { @Override protected Void doInBackground(Void... params) { - ApplicationContext.getInstance(KeyCachingService.this).messageNotifier.updateNotification(KeyCachingService.this); + ApplicationContext.getInstance(KeyCachingService.this).getMessageNotifier().updateNotification(KeyCachingService.this); return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); 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 67bcc326812..a86e9329708 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -1,28 +1,40 @@ package org.thoughtcrime.securesms.sskenvironment import android.content.Context +import dagger.Lazy import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsession.database.StorageProtocol 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.AccountId +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.SessionJobDatabase +import javax.inject.Inject +import javax.inject.Singleton -class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol { +@Singleton +class ProfileManager @Inject constructor( + private val configFactory: ConfigFactoryProtocol, + private val storage: Lazy, + private val contactDatabase: SessionContactDatabase, + private val recipientDatabase: RecipientDatabase, + private val jobDatabase: SessionJobDatabase, + private val preferences: TextSecurePreferences, +) : SSKEnvironment.ProfileManagerProtocol { override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { if (recipient.isLocalNumber) return val accountID = recipient.address.serialize() - val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithAccountID(accountID) if (contact == null) contact = Contact(accountID) - contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) + contact.threadID = storage.get().getThreadId(recipient.address) if (contact.nickname != nickname) { contact.nickname = nickname contactDatabase.setContact(contact) @@ -34,17 +46,15 @@ class ProfileManager(private val context: Context, private val configFactory: Co // New API if (recipient.isLocalNumber) return val accountID = recipient.address.serialize() - val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithAccountID(accountID) if (contact == null) contact = Contact(accountID) - contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) + contact.threadID = storage.get().getThreadId(recipient.address) if (contact.name != name) { contact.name = name contactDatabase.setContact(contact) } // Old API - val database = DatabaseComponent.get(context).recipientDatabase() - database.setProfileName(recipient, name) + recipientDatabase.setProfileName(recipient, name) recipient.notifyListeners() contactUpdatedInternal(contact) } @@ -55,9 +65,7 @@ class ProfileManager(private val context: Context, private val configFactory: Co profilePictureURL: String?, profileKey: ByteArray? ) { - val hasPendingDownload = DatabaseComponent - .get(context) - .sessionJobDatabase() + val hasPendingDownload = jobDatabase .getAllJobs(RetrieveProfileAvatarJob.KEY).any { (it.value as? RetrieveProfileAvatarJob)?.recipientAddress == recipient.address } @@ -65,10 +73,9 @@ class ProfileManager(private val context: Context, private val configFactory: Co recipient.resolve() val accountID = recipient.address.serialize() - val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithAccountID(accountID) if (contact == null) contact = Contact(accountID) - contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) + contact.threadID = storage.get().getThreadId(recipient.address) if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) { contact.profilePictureEncryptionKey = profileKey contact.profilePictureURL = profilePictureURL @@ -82,30 +89,28 @@ class ProfileManager(private val context: Context, private val configFactory: Co } override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) { - val database = DatabaseComponent.get(context).recipientDatabase() - database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) + recipientDatabase.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) } override fun contactUpdatedInternal(contact: Contact): String? { - val contactConfig = configFactory.contacts ?: return null - if (contact.accountID == TextSecurePreferences.getLocalNumber(context)) return null + if (contact.accountID == preferences.getLocalNumber()) return null val accountId = AccountId(contact.accountID) if (accountId.prefix != IdPrefix.STANDARD) return null // only internally store standard account IDs - contactConfig.upsertContact(contact.accountID) { - this.name = contact.name.orEmpty() - this.nickname = contact.nickname.orEmpty() - val url = contact.profilePictureURL - val key = contact.profilePictureEncryptionKey - if (!url.isNullOrEmpty() && key != null && key.size == 32) { - this.profilePicture = UserPic(url, key) - } else if (url.isNullOrEmpty() && key == null) { - this.profilePicture = UserPic.DEFAULT + return configFactory.withMutableUserConfigs { + val contactConfig = it.contacts + contactConfig.upsertContact(contact.accountID) { + this.name = contact.name.orEmpty() + this.nickname = contact.nickname.orEmpty() + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + if (!url.isNullOrEmpty() && key != null && key.size == 32) { + this.profilePicture = UserPic(url, key) + } else if (url.isNullOrEmpty() && key == null) { + this.profilePicture = UserPic.DEFAULT + } } + contactConfig.get(contact.accountID)?.hashCode()?.toString() } - if (contactConfig.needsPush()) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } - return contactConfig.get(contact.accountID)?.hashCode()?.toString() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt index bdf42f0e46e..131657c43bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt @@ -6,18 +6,24 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId -import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import javax.inject.Inject +import javax.inject.Singleton -class ReadReceiptManager: SSKEnvironment.ReadReceiptManagerProtocol { +@Singleton +class ReadReceiptManager @Inject constructor( + private val textSecurePreferences: TextSecurePreferences, + private val mmsSmsDatabase: MmsSmsDatabase, +): SSKEnvironment.ReadReceiptManagerProtocol { override fun processReadReceipts(context: Context, fromRecipientId: String, sentTimestamps: List, readTimestamp: Long) { - if (TextSecurePreferences.isReadReceiptsEnabled(context)) { + if (textSecurePreferences.isReadReceiptsEnabled()) { // Redirect message to master device conversation var address = Address.fromSerialized(fromRecipientId) for (timestamp in sentTimestamps) { Log.i("Loki", "Received encrypted read receipt: (XXXXX, $timestamp)") - DatabaseComponent.get(context).mmsSmsDatabase().incrementReadReceiptCount(SyncMessageId(address, timestamp), readTimestamp) + mmsSmsDatabase.incrementReadReceiptCount(SyncMessageId(address, timestamp), readTimestamp) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java index a18ad8211f4..09cc276a279 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java @@ -27,7 +27,11 @@ import java.util.Set; import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Singleton; + @SuppressLint("UseSparseArrays") +@Singleton public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsProtocol { private static final String TAG = TypingStatusRepository.class.getSimpleName(); @@ -38,17 +42,20 @@ public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsPr private final Map timers; private final Map> notifiers; private final MutableLiveData> threadsNotifier; + private final TextSecurePreferences preferences; - public TypingStatusRepository() { + @Inject + public TypingStatusRepository(TextSecurePreferences preferences) { this.typistMap = new HashMap<>(); this.timers = new HashMap<>(); this.notifiers = new HashMap<>(); this.threadsNotifier = new MutableLiveData<>(); + this.preferences = preferences; } @Override public synchronized void didReceiveTypingStartedMessage(@NotNull Context context, long threadId, @NotNull Address author, int device) { - if (author.serialize().equals(TextSecurePreferences.getLocalNumber(context))) { + if (author.serialize().equals(preferences.getLocalNumber())) { return; } @@ -77,7 +84,7 @@ public synchronized void didReceiveTypingStartedMessage(@NotNull Context context @Override public synchronized void didReceiveTypingStoppedMessage(@NotNull Context context, long threadId, @NotNull Address author, int device, boolean isReplacedByIncomingMessage) { - if (author.serialize().equals(TextSecurePreferences.getLocalNumber(context))) { + if (author.serialize().equals(preferences.getLocalNumber())) { return; } 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 1a8a917f40b..21b67d43d82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -6,27 +6,27 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues +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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn 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.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.ButtonColors import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider @@ -40,17 +40,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -69,6 +71,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.TitledRadioButton import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -76,7 +79,6 @@ import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.transparentButtonColors -import kotlin.math.min import kotlin.math.roundToInt interface Callbacks { @@ -360,43 +362,58 @@ fun Modifier.contentDescription(text: String?): Modifier { return text?.let { semantics { contentDescription = it } } ?: this } -fun Modifier.fadingEdges( - scrollState: ScrollState, - topEdgeHeight: Dp = 0.dp, - bottomEdgeHeight: Dp = 20.dp -): Modifier = this.then( - Modifier - // adding layer fixes issue with blending gradient and content - .graphicsLayer { alpha = 0.99F } - .drawWithContent { - drawContent() - - val topColors = listOf(Color.Transparent, Color.Black) - val topStartY = scrollState.value.toFloat() - val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) - if (topGradientHeight > 0f) drawRect( - brush = Brush.verticalGradient( - colors = topColors, - startY = topStartY, - endY = topStartY + topGradientHeight - ), - blendMode = BlendMode.DstIn - ) +@Composable +fun BottomFadingEdgeBox( + modifier: Modifier = Modifier, + fadingEdgeHeight: Dp = LocalDimensions.current.spacing, + fadingColor: Color = LocalColors.current.background, + content: @Composable BoxScope.(bottomContentPadding: Dp) -> Unit, +) { + Box(modifier) { + this.content(fadingEdgeHeight) - val bottomColors = listOf(Color.Black, Color.Transparent) - val bottomEndY = size.height - scrollState.maxValue + scrollState.value - val bottomGradientHeight = - min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) - if (bottomGradientHeight > 0f) drawRect( - brush = Brush.verticalGradient( - colors = bottomColors, - startY = bottomEndY - bottomGradientHeight, - endY = bottomEndY - ), - blendMode = BlendMode.DstIn - ) - } -) + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .height(fadingEdgeHeight) + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to fadingColor, + tileMode = TileMode.Repeated + ) + ) + ) + } +} + +@Preview +@Composable +private fun BottomFadingEdgeBoxPreview() { + Column(modifier = Modifier.background(LocalColors.current.background)) { + BottomFadingEdgeBox( + modifier = Modifier + .height(600.dp) + .background(LocalColors.current.backgroundSecondary), + content = { bottomContentPadding -> + LazyColumn(contentPadding = PaddingValues(bottom = bottomContentPadding)) { + items(200) { + Text("Item $it", + color = LocalColors.current.text, + style = LocalType.current.base) + } + } + }, + ) + + PrimaryOutlineButton( + modifier = Modifier + .align(Alignment.CenterHorizontally), + text = "Do stuff", onClick = {} + ) + } +} @Composable fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { @@ -427,12 +444,25 @@ fun Avatar( userAddress: Address, modifier: Modifier = Modifier ) { - AndroidView( - factory = { - ProfilePictureView(it).apply { update(userAddress) } - }, - modifier = modifier - ) + if (LocalInspectionMode.current) { + Image( + painterResource(id = R.drawable.ic_profile_default), + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary), + contentScale = ContentScale.Inside, + contentDescription = null, + modifier = Modifier + .size(LocalDimensions.current.iconLarge) + .clip(CircleShape) + .border(1.dp, LocalColors.current.borders, CircleShape) + ) + } else { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(userAddress) } + }, + modifier = modifier + ) + } } @Composable @@ -511,4 +541,58 @@ fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { AnimatedVisibility(!loading) { content() } +} + + +@Composable +fun SearchBar( + query: String, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + backgroundColor: Color = LocalColors.current.background +) { + BasicTextField( + singleLine = true, + value = query, + onValueChange = onValueChanged, + enabled = enabled, + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(100)) + ) { + Image( + painterResource(id = R.drawable.ic_search_24), + contentDescription = null, + colorFilter = ColorFilter.tint( + LocalColors.current.textSecondary + ), + modifier = Modifier + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) + .size(LocalDimensions.current.iconMedium) + ) + + Box(modifier = Modifier.weight(1f)) { + innerTextField() + if (query.isEmpty() && placeholder != null) { + Text( + text = placeholder, + color = LocalColors.current.textSecondary, + style = LocalType.current.xl + ) + } + } + } + }, + textStyle = LocalType.current.base.copy(color = LocalColors.current.text), + modifier = modifier, + cursorBrush = SolidColor(LocalColors.current.text) + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomOptionsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomOptionsDialog.kt new file mode 100644 index 00000000000..8ca33bb8d31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomOptionsDialog.kt @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType + +/** + * A bottom sheet dialog that displays a list of options. + * + * @param options The list of options to display. + * @param onDismissRequest Callback to be invoked when the dialog is to be dismissed. + * @param onOptionClick Callback to be invoked when an option is clicked. + * @param optionTitle A function that returns the title of an option. + * @param optionIconRes A function that returns the icon resource of an option. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomOptionsDialog( + options: Collection, + onDismissRequest: () -> Unit, + onOptionClick: (T) -> Unit, + optionTitle: (T) -> String, + optionIconRes: (T) -> Int, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = RoundedCornerShape( + topStart = LocalDimensions.current.xsSpacing, + topEnd = LocalDimensions.current.xsSpacing + ), + dragHandle = {}, + containerColor = LocalColors.current.backgroundSecondary, + ) { + for (option in options) { + BottomOptionItem( + text = optionTitle(option), + leadingIcon = optionIconRes(option), + onClick = { + onOptionClick(option) + onDismissRequest() + } + ) + } + } +} + +@Composable +private fun BottomOptionItem( + leadingIcon: Int, + text: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(LocalDimensions.current.smallSpacing) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.spacing), + verticalAlignment = CenterVertically, + ) { + Icon( + painter = painterResource(leadingIcon), + modifier = Modifier.size(LocalDimensions.current.iconMedium), + tint = LocalColors.current.text, + contentDescription = null + ) + + Text( + modifier = Modifier.weight(1f), + style = LocalType.current.large, + text = text, + textAlign = TextAlign.Start, + color = LocalColors.current.text, + ) + } +} + + +data class BottomOptionsDialogItem( + val title: String, + val iconRes: Int, + val onClick: () -> Unit, +) + +/** + * A convenience function to display a [BottomOptionsDialog] with a collection of [BottomOptionsDialogItem]. + */ +@Composable +fun BottomOptionsDialog( + items: Collection, + onDismissRequest: () -> Unit +) { + BottomOptionsDialog( + options = items, + onDismissRequest = onDismissRequest, + onOptionClick = { it.onClick() }, + optionTitle = { it.title }, + optionIconRes = { it.iconRes } + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index d2f7a7e73cd..f18069f0803 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,6 +17,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.appendInlineContent @@ -21,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,6 +40,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp import network.loki.messenger.R import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -46,13 +52,16 @@ import org.thoughtcrime.securesms.ui.theme.textSecondary import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.bold +import kotlin.math.sin @Preview @Composable fun PreviewSessionOutlinedTextField() { PreviewTheme { - Column(modifier = Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { SessionOutlinedTextField( text = "text", placeholder = "", @@ -83,63 +92,69 @@ fun PreviewSessionOutlinedTextField() { fun SessionOutlinedTextField( text: String, modifier: Modifier = Modifier, - contentDescription: String? = null, onChange: (String) -> Unit = {}, textStyle: TextStyle = LocalType.current.base, + innerPadding: PaddingValues = PaddingValues(LocalDimensions.current.spacing), placeholder: String = "", onContinue: () -> Unit = {}, error: String? = null, - isTextErrorColor: Boolean = error != null + isTextErrorColor: Boolean = error != null, + enabled: Boolean = true, + singleLine: Boolean = false, ) { - Column(modifier = modifier.animateContentSize()) { - Box( - modifier = Modifier.border( - width = LocalDimensions.current.borderStroke, - color = LocalColors.current.borders(error != null), - shape = MaterialTheme.shapes.small - ) - .fillMaxWidth() - .wrapContentHeight() - .padding(LocalDimensions.current.spacing) - ) { - if (text.isEmpty()) { - Text( - text = placeholder, - style = LocalType.current.base, - color = LocalColors.current.textSecondary(isTextErrorColor), - modifier = Modifier.wrapContentSize() - .align(Alignment.CenterStart) - .wrapContentSize() - ) - } + BasicTextField( + value = text, + onValueChange = onChange, + modifier = modifier, + textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), + cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), + enabled = enabled, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { onContinue() }, + onGo = { onContinue() }, + onSearch = { onContinue() }, + onSend = { onContinue() }, + ), + singleLine = singleLine, + decorationBox = { innerTextField -> + Column(modifier = Modifier.animateContentSize()) { + Box( + modifier = Modifier + .border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.borders(error != null), + shape = MaterialTheme.shapes.small + ) + .fillMaxWidth() + .wrapContentHeight() + .padding(innerPadding) + ) { + innerTextField() - BasicTextField( - value = text, - onValueChange = onChange, - modifier = Modifier.wrapContentHeight().fillMaxWidth().contentDescription(contentDescription), - textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), - cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { onContinue() }, - onGo = { onContinue() }, - onSearch = { onContinue() }, - onSend = { onContinue() }, - ) - ) - } - error?.let { - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - Text( - it, - modifier = Modifier.fillMaxWidth() - .contentDescription(R.string.AccessibilityId_theError), - textAlign = TextAlign.Center, - style = LocalType.current.base.bold(), - color = LocalColors.current.danger - ) + if (placeholder.isNotEmpty() && text.isEmpty()) { + Text( + text = placeholder, + style = textStyle.copy(color = LocalColors.current.textSecondary), + ) + } + } + + error?.let { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Text( + it, + modifier = Modifier + .fillMaxWidth() + .contentDescription(R.string.AccessibilityId_theError), + textAlign = TextAlign.Center, + style = LocalType.current.base.bold(), + color = LocalColors.current.danger + ) + } + } } - } + ) } @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index d1608ea24e1..1697db558bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -18,7 +18,11 @@ data class Dimensions( val appBarHeight: Dp = 64.dp, val minItemButtonHeight: Dp = 50.dp, val minLargeItemButtonHeight: Dp = 60.dp, + val minButtonWidth: Dp = 160.dp, val indicatorHeight: Dp = 4.dp, - val borderStroke: Dp = 1.dp + val borderStroke: Dp = 1.dp, + + val iconMedium: Dp = 24.dp, + val iconLarge: Dp = 48.dp, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index c3d4709b7f1..eb060da11cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -18,6 +18,7 @@ interface ThemeColors { // properties to override for each theme val isLight: Boolean val primary: Color + val textAlert: Color val danger: Color val warning: Color val disabled: Color @@ -115,6 +116,7 @@ data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors override val qrCodeBackground = text override val primaryButtonFill = primary override val primaryButtonFillText = Color.Black + override val textAlert: Color = classicDark0 } data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColors { @@ -134,6 +136,7 @@ data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColor override val qrCodeBackground = backgroundSecondary override val primaryButtonFill = text override val primaryButtonFillText = Color.White + override val textAlert: Color = classicLight0 } data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { @@ -153,6 +156,7 @@ data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val qrCodeBackground = text override val primaryButtonFill = primary override val primaryButtonFillText = Color.Black + override val textAlert: Color = oceanDark0 } data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { @@ -172,6 +176,7 @@ data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val qrCodeBackground = backgroundSecondary override val primaryButtonFill = text override val primaryButtonFillText = Color.White + override val textAlert: Color = oceanLight0 } @Preview @@ -206,5 +211,8 @@ private fun ThemeColors() { Box(Modifier.background(LocalColors.current.borders)) { Text("border", style = LocalType.current.base) } + Box(Modifier.background(LocalColors.current.warning)) { + Text("alertOnWarning", style = LocalType.current.base, color = LocalColors.current.textAlert) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 2f4957565b8..9ef7c23da71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext // Globally accessible composition local objects val LocalColors = compositionLocalOf { ClassicDark() } @@ -32,11 +34,10 @@ fun invalidateComposeThemeColors() { */ @Composable fun SessionMaterialTheme( + preferences: TextSecurePreferences = + (LocalContext.current.applicationContext as ApplicationContext).textSecurePreferences, content: @Composable () -> Unit ) { - val context = LocalContext.current - val preferences = AppTextSecurePreferences(context) - val cachedColors = cachedColorsProvider ?: preferences.getColorsProvider().also { cachedColorsProvider = it } SessionMaterialTheme( 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 00000000000..398bcbcc04f --- /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 e59d3aae178..19f95c6374b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -1,238 +1,19 @@ package org.thoughtcrime.securesms.util -import android.content.Context -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.UserGroupsConfig -import network.loki.messenger.libsession_util.UserProfile -import network.loki.messenger.libsession_util.util.BaseCommunityInfo -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 -import org.session.libsession.messaging.messages.Destination -import org.session.libsession.messaging.messages.control.ConfigurationMessage -import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.TextSecurePreferences -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.toHexString import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import java.util.Timer object ConfigurationMessageUtilities { - private const val TAG = "ConfigMessageUtils" - - private val debouncer = WindowDebouncer(3000, Timer()) - - private fun scheduleConfigSync(userPublicKey: String) { - - 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 newConfigSyncJob = ConfigurationSyncJob(ourDestination) - JobQueue.shared.add(newConfigSyncJob) - } - } - - @JvmStatic - fun syncConfigurationIfNeeded(context: Context) { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.w(TAG, "User Public Key is null") - scheduleConfigSync(userPublicKey) - } - - fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) - // Schedule a new job if one doesn't already exist (only) - scheduleConfigSync(userPublicKey) - return Promise.ofSuccess(Unit) - } - - private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes - - fun generateUserProfileConfigDump(): ByteArray? { - val storage = MessagingModuleConfiguration.shared.storage - val ownPublicKey = storage.getUserPublicKey() ?: return null - val config = ConfigurationMessage.getCurrent(listOf()) ?: return null - val secretKey = maybeUserSecretKey() ?: return null - val profile = UserProfile.newInstance(secretKey) - profile.setName(config.displayName) - val picUrl = config.profilePicture - val picKey = config.profileKey - if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { - profile.setPic(UserPic(picUrl, picKey)) - } - val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) - profile.setNtsPriority( - if (ownThreadId != null) - if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE - else ConfigBase.PRIORITY_HIDDEN - ) - val dump = profile.dump() - profile.free() - return dump - } - - fun generateContactConfigDump(): ByteArray? { - val secretKey = maybeUserSecretKey() ?: return null - val storage = MessagingModuleConfiguration.shared.storage - val localUserKey = storage.getUserPublicKey() ?: return null - val contactsWithSettings = storage.getAllContacts().filter { recipient -> - recipient.accountID != localUserKey && recipient.accountID.startsWith(IdPrefix.STANDARD.value) - && storage.getThreadId(recipient.accountID) != null - }.map { contact -> - val address = Address.fromSerialized(contact.accountID) - val thread = storage.getThreadId(address) - val isPinned = if (thread != null) { - storage.isPinned(thread) - } else false - - Triple(contact, storage.getRecipientSettings(address)!!, isPinned) - } - val contactConfig = Contacts.newInstance(secretKey) - for ((contact, settings, isPinned) in contactsWithSettings) { - val url = contact.profilePictureURL - val key = contact.profilePictureEncryptionKey - val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) { - null - } else { - UserPic(url, key) - } - - val contactInfo = Contact( - id = contact.accountID, - name = contact.name.orEmpty(), - nickname = contact.nickname.orEmpty(), - blocked = settings.isBlocked, - approved = settings.isApproved, - approvedMe = settings.hasApprovedMe(), - profilePicture = userPic ?: UserPic.DEFAULT, - priority = if (isPinned) 1 else 0, - expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong()) - ) - contactConfig.set(contactInfo) - } - val dump = contactConfig.dump() - contactConfig.free() - if (dump.isEmpty()) return null - return dump - } - - fun generateConversationVolatileDump(context: Context): ByteArray? { - val secretKey = maybeUserSecretKey() ?: return null - val storage = MessagingModuleConfiguration.shared.storage - val convoConfig = ConversationVolatileConfig.newInstance(secretKey) - val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.approvedConversationList.use { cursor -> - val reader = threadDb.readerFor(cursor) - var current = reader.next - while (current != null) { - val recipient = current.recipient - val contact = when { - recipient.isCommunityRecipient -> { - val openGroup = storage.getOpenGroup(current.threadId) ?: continue - val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue - convoConfig.getOrConstructCommunity(base, room, pubKey) - } - recipient.isClosedGroupRecipient -> { - val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) - convoConfig.getOrConstructLegacyGroup(groupPublicKey) - } - recipient.isContactRecipient -> { - if (recipient.isLocalNumber) null // this is handled by the user profile NTS data - else if (recipient.isOpenGroupInboxRecipient) null // specifically exclude - else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null - else convoConfig.getOrConstructOneToOne(recipient.address.serialize()) - } - else -> null - } - if (contact == null) { - current = reader.next - continue - } - contact.lastRead = current.lastSeen - contact.unread = false - convoConfig.set(contact) - current = reader.next - } - } - - val dump = convoConfig.dump() - convoConfig.free() - if (dump.isEmpty()) return null - return dump - } - - fun generateUserGroupDump(context: Context): ByteArray? { - val secretKey = maybeUserSecretKey() ?: return null - val storage = MessagingModuleConfiguration.shared.storage - val groupConfig = UserGroupsConfig.newInstance(secretKey) - val allOpenGroups = storage.getAllOpenGroups().values.mapNotNull { openGroup -> - val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null - val pubKeyHex = Hex.toStringCondensed(pubKey) - val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex) - val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null - val isPinned = storage.isPinned(threadId) - GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0) - } - - val allLgc = storage.getAllGroups(includeInactive = false).filter { - it.isClosedGroup && it.isActive && it.members.size > 1 - }.mapNotNull { group -> - val groupAddress = Address.fromSerialized(group.encodedId) - val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() - val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null - val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null - val threadId = storage.getThreadId(group.encodedId) - val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false - 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( - accountId = groupPublicKey, - name = group.title, - members = admins + members, - priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, - encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte - encSecKey = encryptionKeyPair.privateKey.serialize(), - disappearingTimer = recipient.expireMessages.toLong(), - joinedAt = (group.formationTimestamp / 1000L) - ) - } - (allOpenGroups + allLgc).forEach { groupInfo -> - groupConfig.set(groupInfo) - } - val dump = groupConfig.dump() - groupConfig.free() - if (dump.isEmpty()) return null - return dump - } - @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.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_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.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_INBOX_PREFIX}%'; """.trimIndent() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index d6b71b31511..ce5b8916cc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -6,6 +6,8 @@ import android.content.Intent import android.content.IntentFilter import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.opencsv.CSVReader +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.session.libsession.snode.OnionRequestAPI import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils @@ -103,7 +105,7 @@ class IP2Country internal constructor( } private fun populateCacheIfNeeded() { - ThreadUtils.queue { + GlobalScope.launch { val start = System.currentTimeMillis() OnionRequestAPI.paths.iterator().forEach { path -> path.iterator().forEach { snode -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt index c10e1b635dc..cf140baefe3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt @@ -40,7 +40,7 @@ object SessionMetaProtocol { @JvmStatic fun shouldSendDeliveryReceipt(message: SignalServiceDataMessage, address: Address): Boolean { - if (address.isGroup) { return false } + if (address.isGroupOrCommunity) { return false } val hasBody = message.body.isPresent && message.body.get().isNotEmpty() val hasAttachment = message.attachments.isPresent && message.attachments.get().isNotEmpty() val hasLinkPreview = message.previews.isPresent && message.previews.get().isNotEmpty() @@ -49,11 +49,11 @@ object SessionMetaProtocol { @JvmStatic fun shouldSendReadReceipt(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked + return !recipient.isGroupOrCommunityRecipient && recipient.isApproved && !recipient.isBlocked } @JvmStatic fun shouldSendTypingIndicator(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked + return !recipient.isGroupOrCommunityRecipient && recipient.isApproved && !recipient.isBlocked } } \ 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 3984f38b516..d044d9f8c0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -1,18 +1,20 @@ package org.thoughtcrime.securesms.util -import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.ReadableConversationVolatileConfig import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.GroupUtil import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.model.ThreadRecord -fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { +fun ReadableConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { val recipient = thread.recipient if (recipient.isContactRecipient - && recipient.isOpenGroupInboxRecipient + && recipient.isCommunityInboxRecipient && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { return getOneToOne(recipient.address.serialize())?.unread == true - } else if (recipient.isClosedGroupRecipient) { + } else if (recipient.isGroupV2Recipient) { + return getClosedGroup(recipient.address.serialize())?.unread == true + } else if (recipient.isLegacyGroupRecipient) { return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true } else if (recipient.isCommunityRecipient) { val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false 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 00000000000..e0a28b77e4c --- /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 00000000000..b0b326ca3d3 --- /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 00000000000..a9b3bdfdd1e --- /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 00000000000..e79703910d7 --- /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_disappearing_messages.xml b/app/src/main/res/drawable/ic_disappearing_messages.xml new file mode 100644 index 00000000000..1e2de4e757c --- /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 00000000000..f647fea3ea2 --- /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_images.xml b/app/src/main/res/drawable/ic_images.xml new file mode 100644 index 00000000000..4e8b9abe3e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_images.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + 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 00000000000..a6a235aeb7f --- /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_log_out.xml b/app/src/main/res/drawable/ic_log_out.xml new file mode 100644 index 00000000000..1ae65b31e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_log_out.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml new file mode 100644 index 00000000000..67a21c3500b --- /dev/null +++ b/app/src/main/res/drawable/ic_mail.xml @@ -0,0 +1,14 @@ + + + + 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 00000000000..e3dea6f2a2d --- /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 00000000000..b2ff304b359 --- /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_question_mark.xml b/app/src/main/res/drawable/ic_question_mark.xml new file mode 100644 index 00000000000..d0c4088dcfe --- /dev/null +++ b/app/src/main/res/drawable/ic_question_mark.xml @@ -0,0 +1,15 @@ + + + + + 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 00000000000..bd9eaad36cc --- /dev/null +++ b/app/src/main/res/drawable/ic_search_conversation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/preference_single_no_padding.xml b/app/src/main/res/drawable/preference_single_no_padding.xml new file mode 100644 index 00000000000..483894fcc29 --- /dev/null +++ b/app/src/main/res/drawable/preference_single_no_padding.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_picture_view_large_background.xml b/app/src/main/res/drawable/profile_picture_view_large_background.xml new file mode 100644 index 00000000000..9b90660803f --- /dev/null +++ b/app/src/main/res/drawable/profile_picture_view_large_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index cdc325160dc..11597ca1d2a 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -16,6 +16,7 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintVertical_bias="0" android:theme="@style/Widget.Session.ActionBar.Flat" android:background="?colorPrimary" app:contentInsetStart="0dp"> @@ -39,7 +40,7 @@ app:layout_constraintVertical_weight="1" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer" - app:layout_constraintTop_toBottomOf="@id/toolbar" /> + app:layout_constraintTop_toBottomOf="@id/outdatedGroupBanner" /> + > + + @@ -295,6 +316,7 @@ tools:visibility="gone"> - - + 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 006a493922e..2445fde11c3 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.EditLegacyGroupActivity"> + android:text="@string/contactUserDetails" + app:drawableStartCompat="@drawable/ic_info_outline_white_24dp" + app:drawableTint="?attr/colorControlNormal" /> + android:text="@string/accountIDCopy" + app:drawableStartCompat="@drawable/ic_copy" + app:drawableTint="?attr/colorControlNormal" /> + android:text="@string/communityUrlCopy" + app:drawableStartCompat="@drawable/ic_copy" + app:drawableTint="?attr/colorControlNormal" /> @@ -80,8 +85,9 @@ @@ -89,8 +95,9 @@ @@ -98,16 +105,18 @@ + tools:visibility="visible" + app:drawableTint="?attr/colorControlNormal" + app:drawableStartCompat="@drawable/ic_outline_mark_chat_read_24" /> + android:text="@string/delete" + app:drawableTint="?attr/colorControlNormal" + tools:drawableStartCompat="@drawable/ic_baseline_delete_24" /> diff --git a/app/src/main/res/layout/view_pending_attachment.xml b/app/src/main/res/layout/view_pending_attachment.xml new file mode 100644 index 00000000000..ba37226f3b9 --- /dev/null +++ b/app/src/main/res/layout/view_pending_attachment.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_untrusted_attachment.xml b/app/src/main/res/layout/view_untrusted_attachment.xml deleted file mode 100644 index 0af509076d9..00000000000 --- a/app/src/main/res/layout/view_untrusted_attachment.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index 7c22cca586c..a954c312284 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -27,13 +27,13 @@ app:layout_constraintHorizontal_bias="0" /> - diff --git a/app/src/main/res/menu/menu_conversation_groups_v2.xml b/app/src/main/res/menu/menu_conversation_groups_v2.xml new file mode 100644 index 00000000000..e5192789319 --- /dev/null +++ b/app/src/main/res/menu/menu_conversation_groups_v2.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_groups_v2_admin.xml b/app/src/main/res/menu/menu_conversation_groups_v2_admin.xml new file mode 100644 index 00000000000..2cab0f9c5bd --- /dev/null +++ b/app/src/main/res/menu/menu_conversation_groups_v2_admin.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_closed_group.xml b/app/src/main/res/menu/menu_conversation_legacy_group.xml similarity index 100% rename from app/src/main/res/menu/menu_conversation_closed_group.xml rename to app/src/main/res/menu/menu_conversation_legacy_group.xml diff --git a/app/src/main/res/menu/menu_group_request.xml b/app/src/main/res/menu/menu_group_request.xml new file mode 100644 index 00000000000..367815d06f1 --- /dev/null +++ b/app/src/main/res/menu/menu_group_request.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 716d81601f7..9e325cbf629 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ + #FCB159 + #D8D8D8 #353535 #161616 #36383C diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 5504aba7ee6..2233037e81b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -87,7 +87,7 @@ 40dp - 3 + 4 10dp @@ -126,5 +126,6 @@ 200dp 34dp 26dp + 26dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 045e125f3d8..5f382135754 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,3 +1,6 @@ + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index bc3f5e8fa24..c6abb3ae300 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -18,6 +18,54 @@ @drawable/ic_arrow_left + + + + + + + + + + + +