mirror of
https://github.com/mollyim/mollyim-android.git
synced 2025-05-12 13:20:37 +01:00
Merge tag 'v7.32.1' into molly-7.32
This commit is contained in:
commit
dc5f599a87
232 changed files with 17009 additions and 9957 deletions
|
@ -11,8 +11,8 @@ plugins {
|
|||
id("molly")
|
||||
}
|
||||
|
||||
val canonicalVersionCode = 1506
|
||||
val canonicalVersionName = "7.31.1"
|
||||
val canonicalVersionCode = 1508
|
||||
val canonicalVersionName = "7.32.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
val mollyRevision = 1
|
||||
|
@ -426,6 +426,7 @@ dependencies {
|
|||
implementation(project(":core-ui"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
|
@ -562,6 +563,8 @@ dependencies {
|
|||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
testImplementation(testLibs.espresso.core)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(testLibs.espresso.core)
|
||||
androidTestImplementation(testLibs.androidx.test.core)
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,173 @@
|
|||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.billing.BillingProduct
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.billing.BillingPurchaseState
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageBackupsCheckoutActivityTest {
|
||||
|
||||
@get:Rule val activityRule = SignalActivityRule()
|
||||
|
||||
@get:Rule val iapRule = InAppPaymentsRule()
|
||||
|
||||
@get:Rule val composeTestRule = createEmptyComposeRule()
|
||||
|
||||
private val purchaseResults = MutableSharedFlow<BillingPurchaseResult>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { AppDependencies.billingApi.getBillingPurchaseResults() } returns purchaseResults
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
|
||||
coEvery { AppDependencies.billingApi.launchBillingFlow(any()) } returns Unit
|
||||
|
||||
mockkStatic(RemoteConfig::class)
|
||||
every { RemoteConfig.messageBackups } returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e2e_paid_happy_path() {
|
||||
val scenario = launchCheckoutFlow()
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
e2e_shared_happy_path(context, scenario)
|
||||
|
||||
composeTestRule.onNodeWithTag("message-backups-type-selection-screen-lazy-column")
|
||||
.performScrollToNode(hasText(context.getString(R.string.MessageBackupsTypeSelectionScreen__text_plus_all_your_media)))
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__text_plus_all_your_media)).performClick()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).assertIsEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
runBlocking {
|
||||
purchaseResults.emit(
|
||||
BillingPurchaseResult.Success(
|
||||
purchaseState = BillingPurchaseState.PURCHASED,
|
||||
purchaseToken = "asdf",
|
||||
isAcknowledged = false,
|
||||
purchaseTime = System.currentTimeMillis(),
|
||||
isAutoRenewing = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithTag("dialog-circular-progress-indicator").assertIsDisplayed()
|
||||
|
||||
val iap = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
|
||||
assertThat(iap?.state).isEqualTo(InAppPaymentTable.State.PENDING)
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = iap!!.copy(
|
||||
state = InAppPaymentTable.State.END
|
||||
)
|
||||
)
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithTag("dialog-circular-progress-indicator").assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e2e_free_happy_path() {
|
||||
val scenario = launchCheckoutFlow()
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
e2e_shared_happy_path(context, scenario)
|
||||
|
||||
composeTestRule.onNodeWithTag("message-backups-type-selection-screen-lazy-column")
|
||||
.performScrollToNode(hasText(context.getString(R.string.MessageBackupsTypeSelectionScreen__free)))
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__free)).performClick()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).assertIsEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(SignalStore.backup.backupTier).isEqualTo(MessageBackupTier.FREE)
|
||||
}
|
||||
|
||||
private fun e2e_shared_happy_path(context: Context, scenario: ActivityScenario<MessageBackupsCheckoutActivity>) {
|
||||
assertThat(SignalStore.backup.backupTier).isNull()
|
||||
|
||||
// Backup education screen
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.RemoteBackupsSettingsFragment__signal_backups)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsEducationScreen__enable_backups)).performClick()
|
||||
|
||||
// Key education screen
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyEducationScreen__your_backup_key)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
|
||||
|
||||
// Key record screen
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("message-backups-key-record-screen-lazy-column")
|
||||
.performScrollToNode(hasText(context.getString(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)))
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)).performClick()
|
||||
|
||||
scenario.onActivity {
|
||||
val backupKeyString = SignalStore.account.accountEntropyPool.value.chunked(4).joinToString(" ")
|
||||
val clipboardManager = ContextCompat.getSystemService(context, ClipboardManager::class.java)
|
||||
assertThat(clipboardManager?.primaryClip?.getItemAt(0)?.coerceToText(context)).isEqualTo(backupKeyString)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
|
||||
|
||||
// Key record bottom sheet
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("message-backups-key-record-screen-sheet-content")
|
||||
.performScrollToNode(hasText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)))
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).assertIsNotEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key)).performClick()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).assertIsEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).performClick()
|
||||
|
||||
// Type selection screen
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessagesBackupsTypeSelectionScreen__choose_your_backup_plan)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
private fun launchCheckoutFlow(tier: MessageBackupTier? = null): ActivityScenario<MessageBackupsCheckoutActivity> {
|
||||
return ActivityScenario.launch(
|
||||
MessageBackupsCheckoutActivity.Contract().createIntent(InstrumentationRegistry.getInstrumentation().targetContext, tier)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -12,7 +12,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
|
|||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
@ -25,24 +24,26 @@ import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDepende
|
|||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.Delete
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Ignore("Test fails on small screens, requires scrolling.")
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CheckoutFlowActivityTest__RecurringDonations {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
|
||||
|
||||
@Test
|
||||
|
@ -54,25 +55,27 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
|||
@Test
|
||||
fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() {
|
||||
ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
onView(withText("Continue")).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACurrentDonation_whenILoadScreen_thenIExpectUpgradeButton() {
|
||||
initialiseConfigurationResponse()
|
||||
initialiseActiveSubscription()
|
||||
|
||||
ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACurrentDonation_whenIPressCancel_thenIExpectCancellationDialog() {
|
||||
initialiseConfigurationResponse()
|
||||
initialiseActiveSubscription()
|
||||
|
||||
ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click())
|
||||
onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed()))
|
||||
|
@ -82,25 +85,14 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
|||
|
||||
@Test
|
||||
fun givenAPendingRecurringDonation_whenILoadScreen_thenIExpectDisabledUpgradeButton() {
|
||||
initialiseConfigurationResponse()
|
||||
initialisePendingSubscription()
|
||||
|
||||
ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
|
||||
}
|
||||
|
||||
private fun initialiseConfigurationResponse() {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/subscription/configuration") {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
assets.open("inAppPaymentsTests/configuration.json").use { stream ->
|
||||
MockResponse().success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialiseActiveSubscription() {
|
||||
val currency = Currency.getInstance("USD")
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
|
||||
/**
|
||||
* Sets up some common infrastructure for on-device InAppPayment testing
|
||||
*/
|
||||
class InAppPaymentsRule : ExternalResource() {
|
||||
override fun before() {
|
||||
initialiseConfigurationResponse()
|
||||
initialisePutSubscription()
|
||||
}
|
||||
|
||||
private fun initialiseConfigurationResponse() {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/subscription/configuration") {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
assets.open("inAppPaymentsTests/configuration.json").use { stream ->
|
||||
MockResponse().success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialisePutSubscription() {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/subscription/") {
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing.actions
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import org.hamcrest.CoreMatchers.allOf
|
||||
import org.hamcrest.Matcher
|
||||
|
||||
/**
|
||||
* Scrolls the RecyclerView to the bottom position.
|
||||
*
|
||||
* Borrowed from [https://stackoverflow.com/a/55990445](https://stackoverflow.com/a/55990445)
|
||||
*/
|
||||
object RecyclerViewScrollToBottomAction : ViewAction {
|
||||
override fun getDescription(): String = "scroll RecyclerView to bottom"
|
||||
|
||||
override fun getConstraints(): Matcher<View> = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
|
||||
|
||||
override fun perform(uiController: UiController?, view: View?) {
|
||||
val recyclerView = view as RecyclerView
|
||||
val itemCount = recyclerView.adapter?.itemCount
|
||||
val position = itemCount?.minus(1) ?: 0
|
||||
recyclerView.scrollToPosition(position)
|
||||
uiController?.loopMainThreadUntilIdle()
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
|
|||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobs.DeleteAbandonedAttachmentsJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
@ -55,5 +56,6 @@ public final class AppInitialization {
|
|||
AppDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
|
||||
AppDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
|
||||
EmojiSearchIndexDownloadJob.scheduleImmediately();
|
||||
DeleteAbandonedAttachmentsJob.enqueue();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,10 @@ object ExportSkips {
|
|||
return log(sentTimestamp, "Direct story reply has no body.")
|
||||
}
|
||||
|
||||
fun directStoryReplyInNoteToSelf(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Direct story reply in Note to Self.")
|
||||
}
|
||||
|
||||
fun invalidChatItemStickerPackId(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Sticker message had an invalid packId.")
|
||||
}
|
||||
|
@ -79,6 +83,34 @@ object ExportSkips {
|
|||
return log(sentTimestamp, "Identity verified update for ourselves.")
|
||||
}
|
||||
|
||||
fun fromRecipientIsNotAnIndividual(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "The fromRecipient does not represent an individual person.")
|
||||
}
|
||||
|
||||
fun oneOnOneMessageInTheWrongChat(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "A 1:1 message is located in the wrong chat.")
|
||||
}
|
||||
|
||||
fun paymentNotificationInNoteToSelf(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Payment notification is in Note to Self.")
|
||||
}
|
||||
|
||||
fun profileChangeInNoteToSelf(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Profile change in Note to Self.")
|
||||
}
|
||||
|
||||
fun profileChangeFromSelf(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Profile change from self.")
|
||||
}
|
||||
|
||||
fun emptyProfileNameChange(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Profile name change was empty.")
|
||||
}
|
||||
|
||||
fun emptyLearnedProfileChange(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Learned profile update was empty.")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
|
@ -122,6 +154,10 @@ object ExportOddities {
|
|||
return log(0, "Distribution list had self as a member. Removing it.")
|
||||
}
|
||||
|
||||
fun emptyQuote(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[ODDITY][$sentTimestamp] $message"
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import androidx.annotation.Discouraged
|
|||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.util.Base64
|
||||
|
@ -1523,7 +1524,11 @@ data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
|
|||
class ExportState(val backupTime: Long, val mediaBackupEnabled: Boolean) {
|
||||
val recipientIds: MutableSet<Long> = hashSetOf()
|
||||
val threadIds: MutableSet<Long> = hashSetOf()
|
||||
val localToRemoteCustomChatColors: MutableMap<Long, Int> = hashMapOf()
|
||||
val contactRecipientIds: MutableSet<Long> = hashSetOf()
|
||||
val groupRecipientIds: MutableSet<Long> = hashSetOf()
|
||||
val threadIdToRecipientId: MutableMap<Long, Long> = hashMapOf()
|
||||
val recipientIdToAci: MutableMap<Long, ByteString> = hashMapOf()
|
||||
val aciToRecipientId: MutableMap<String, Long> = hashMapOf()
|
||||
}
|
||||
|
||||
class ImportState(val mediaRootBackupKey: MediaRootBackupKey) {
|
||||
|
|
|
@ -27,7 +27,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
|||
"""CREATE INDEX $dateReceivedIndex ON ${MessageTable.TABLE_NAME} (
|
||||
${MessageTable.DATE_RECEIVED} ASC,
|
||||
${MessageTable.STORY_TYPE},
|
||||
${MessageTable.ID},
|
||||
${MessageTable.PARENT_STORY_ID},
|
||||
${MessageTable.DATE_SENT},
|
||||
${MessageTable.DATE_SERVER},
|
||||
${MessageTable.TYPE},
|
||||
|
@ -59,9 +59,9 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
|||
${MessageTable.MISMATCHED_IDENTITIES},
|
||||
${MessageTable.TYPE},
|
||||
${MessageTable.MESSAGE_EXTRAS},
|
||||
${MessageTable.VIEW_ONCE},
|
||||
${MessageTable.PARENT_STORY_ID}
|
||||
${MessageTable.VIEW_ONCE}
|
||||
)
|
||||
WHERE ${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.PARENT_STORY_ID} <= 0
|
||||
""".trimMargin()
|
||||
)
|
||||
Log.d(TAG, "Creating index took ${System.currentTimeMillis() - startTime} ms")
|
||||
|
@ -87,6 +87,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
|||
batchSize = 10_000,
|
||||
mediaArchiveEnabled = mediaBackupEnabled,
|
||||
selfRecipientId = selfRecipientId,
|
||||
noteToSelfThreadId = db.threadTable.getThreadIdFor(selfRecipientId) ?: -1L,
|
||||
exportState = exportState,
|
||||
cursorGenerator = { lastSeenReceivedTime, count ->
|
||||
readableDatabase
|
||||
|
@ -128,7 +129,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
|||
MessageTable.PARENT_STORY_ID
|
||||
)
|
||||
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
|
||||
.where("${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.DATE_RECEIVED} >= $lastSeenReceivedTime")
|
||||
.where("${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.PARENT_STORY_ID} <= 0 AND ${MessageTable.DATE_RECEIVED} >= $lastSeenReceivedTime")
|
||||
.limit(count)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
|
||||
.run()
|
||||
|
|
|
@ -35,7 +35,6 @@ fun ThreadTable.getThreadsForBackup(db: SignalDatabase, includeImageWallpapers:
|
|||
FROM ${ThreadTable.TABLE_NAME}
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
(${ThreadTable.ACTIVE} = 1 OR ${RecipientTable.MESSAGE_EXPIRATION_TIME} > 0 OR ${RecipientTable.MUTE_UNTIL} > 0 OR ${ThreadTable.ARCHIVED} != 0) AND
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} NOT IN (${RecipientTable.RecipientType.DISTRIBUTION_LIST.id}, ${RecipientTable.RecipientType.CALL_LINK.id})
|
||||
"""
|
||||
val cursor = readableDatabase.query(query)
|
||||
|
|
|
@ -12,7 +12,9 @@ import org.json.JSONException
|
|||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.ParallelEventTimer
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.nullIfEmpty
|
||||
|
@ -28,7 +30,6 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
|||
import org.thoughtcrime.securesms.backup.v2.ExportOddities
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getThreadGroupStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
|
||||
|
@ -84,7 +85,6 @@ import org.thoughtcrime.securesms.mms.QuoteModel
|
|||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.io.Closeable
|
||||
|
@ -94,7 +94,6 @@ import java.util.Queue
|
|||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Future
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
|
||||
|
@ -112,6 +111,7 @@ private val TAG = Log.tag(ChatItemArchiveExporter::class.java)
|
|||
class ChatItemArchiveExporter(
|
||||
private val db: SignalDatabase,
|
||||
private val selfRecipientId: RecipientId,
|
||||
private val noteToSelfThreadId: Long,
|
||||
private val backupStartTime: Long,
|
||||
private val batchSize: Int,
|
||||
private val mediaArchiveEnabled: Boolean,
|
||||
|
@ -119,8 +119,15 @@ class ChatItemArchiveExporter(
|
|||
private val cursorGenerator: (Long, Int) -> Cursor
|
||||
) : Iterator<ChatItem?>, Closeable {
|
||||
|
||||
/** Timer for more macro-level events, like fetching extra data vs transforming the data. */
|
||||
private val eventTimer = EventTimer()
|
||||
|
||||
/** Timer for just the transformation process, to see what types of transformations are taking more time. */
|
||||
private val transformTimer = EventTimer()
|
||||
|
||||
/** Timer for fetching extra data. */
|
||||
private val extraDataTimer = ParallelEventTimer()
|
||||
|
||||
/**
|
||||
* A queue of already-parsed ChatItems. Processing in batches means that we read ahead in the cursor and put
|
||||
* the pending items here.
|
||||
|
@ -144,9 +151,11 @@ class ChatItemArchiveExporter(
|
|||
|
||||
val extraData = fetchExtraMessageData(db, records.keys)
|
||||
eventTimer.emit("extra-data")
|
||||
transformTimer.emit("ignore")
|
||||
|
||||
for ((id, record) in records) {
|
||||
val builder = record.toBasicChatItemBuilder(selfRecipientId, extraData.isGroupThreadById[id] ?: false, extraData.groupReceiptsById[id], exportState, backupStartTime)
|
||||
val builder = record.toBasicChatItemBuilder(selfRecipientId, extraData.groupReceiptsById[id], exportState, backupStartTime)
|
||||
transformTimer.emit("basic")
|
||||
|
||||
if (builder == null) {
|
||||
continue
|
||||
|
@ -155,10 +164,12 @@ class ChatItemArchiveExporter(
|
|||
when {
|
||||
record.remoteDeleted -> {
|
||||
builder.remoteDeletedMessage = RemoteDeletedMessage()
|
||||
transformTimer.emit("remote-delete")
|
||||
}
|
||||
|
||||
MessageTypes.isJoinedType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isIdentityUpdate(record.type) -> {
|
||||
|
@ -167,6 +178,7 @@ class ChatItemArchiveExporter(
|
|||
continue
|
||||
}
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_UPDATE)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isIdentityVerified(record.type) -> {
|
||||
|
@ -175,6 +187,7 @@ class ChatItemArchiveExporter(
|
|||
continue
|
||||
}
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_VERIFIED)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isIdentityDefault(record.type) -> {
|
||||
|
@ -183,26 +196,32 @@ class ChatItemArchiveExporter(
|
|||
continue
|
||||
}
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_DEFAULT)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isChangeNumber(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHANGE_NUMBER)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isEndSessionType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.END_SESSION)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isChatSessionRefresh(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHAT_SESSION_REFRESH)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isBadDecryptType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BAD_DECRYPT)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isPaymentsActivated(record.type) -> {
|
||||
|
@ -215,45 +234,59 @@ class ChatItemArchiveExporter(
|
|||
|
||||
MessageTypes.isUnsupportedMessageType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isReportedSpam(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.REPORTED_SPAM)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isMessageRequestAccepted(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isBlocked(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BLOCKED)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isUnblocked(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNBLOCKED)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) -> {
|
||||
if (db.threadTable.getThreadRecord(record.threadId)?.recipient?.isGroup == true) {
|
||||
builder.updateMessage = record.toRemoteGroupExpireTimerUpdateFromGv1(db) ?: continue
|
||||
if (exportState.threadIdToRecipientId[record.threadId] in exportState.groupRecipientIds) {
|
||||
builder.updateMessage = record.toRemoteGroupExpireTimerUpdateFromGv1(exportState) ?: continue
|
||||
} else {
|
||||
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn))
|
||||
}
|
||||
|
||||
builder.expireStartDate = null
|
||||
builder.expiresInMs = null
|
||||
transformTimer.emit("expire-update")
|
||||
}
|
||||
|
||||
MessageTypes.isProfileChange(record.type) -> {
|
||||
if (record.threadId == noteToSelfThreadId) {
|
||||
Log.w(TAG, ExportSkips.profileChangeInNoteToSelf(record.dateSent))
|
||||
continue
|
||||
}
|
||||
|
||||
builder.updateMessage = record.toRemoteProfileChangeUpdate() ?: continue
|
||||
transformTimer.emit("profile-change")
|
||||
}
|
||||
|
||||
MessageTypes.isSessionSwitchoverType(record.type) -> {
|
||||
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate()
|
||||
transformTimer.emit("sse")
|
||||
}
|
||||
|
||||
MessageTypes.isThreadMergeType(record.type) -> {
|
||||
builder.updateMessage = record.toRemoteThreadMergeUpdate() ?: continue
|
||||
transformTimer.emit("thread-merge")
|
||||
}
|
||||
|
||||
MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> {
|
||||
|
@ -263,10 +296,12 @@ class ChatItemArchiveExporter(
|
|||
continue
|
||||
}
|
||||
builder.updateMessage = update
|
||||
transformTimer.emit("group-update-v2")
|
||||
}
|
||||
|
||||
MessageTypes.isGroupUpdate(record.type) || MessageTypes.isGroupQuit(record.type) -> {
|
||||
builder.updateMessage = record.toRemoteGroupUpdateFromGv1(db) ?: continue
|
||||
builder.updateMessage = record.toRemoteGroupUpdateFromGv1(exportState) ?: continue
|
||||
transformTimer.emit("group-update-v1")
|
||||
}
|
||||
|
||||
MessageTypes.isGroupV1MigrationEvent(record.type) -> {
|
||||
|
@ -275,31 +310,41 @@ class ChatItemArchiveExporter(
|
|||
updates = listOf(GroupChangeChatUpdate.Update(groupV2MigrationUpdate = GroupV2MigrationUpdate()))
|
||||
)
|
||||
)
|
||||
transformTimer.emit("gv1-migration")
|
||||
}
|
||||
|
||||
MessageTypes.isCallLog(record.type) -> {
|
||||
val call = db.callTable.getCallByMessageId(record.id)
|
||||
builder.updateMessage = call?.toRemoteCallUpdate(db, record) ?: continue
|
||||
builder.updateMessage = call?.toRemoteCallUpdate(exportState, record) ?: continue
|
||||
transformTimer.emit("call-log")
|
||||
}
|
||||
|
||||
MessageTypes.isPaymentsNotification(record.type) -> {
|
||||
builder.paymentNotification = null
|
||||
continue
|
||||
}
|
||||
|
||||
MessageTypes.isGiftBadge(record.type) -> {
|
||||
builder.giftBadge = record.toRemoteGiftBadgeUpdate() ?: continue
|
||||
transformTimer.emit("gift-badge")
|
||||
}
|
||||
|
||||
!record.sharedContacts.isNullOrEmpty() -> {
|
||||
builder.contactMessage = record.toRemoteContactMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[id]) ?: continue
|
||||
transformTimer.emit("contact")
|
||||
}
|
||||
|
||||
record.viewOnce -> {
|
||||
builder.viewOnceMessage = record.toRemoteViewOnceMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[id])
|
||||
transformTimer.emit("voice")
|
||||
}
|
||||
|
||||
record.parentStoryId != 0L -> {
|
||||
if (record.threadId == noteToSelfThreadId) {
|
||||
Log.w(TAG, ExportSkips.directStoryReplyInNoteToSelf(record.dateSent))
|
||||
continue
|
||||
}
|
||||
builder.directStoryReplyMessage = record.toRemoteDirectStoryReplyMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[record.id]) ?: continue
|
||||
transformTimer.emit("story")
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
@ -315,7 +360,7 @@ class ChatItemArchiveExporter(
|
|||
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, mediaArchiveEnabled = mediaArchiveEnabled, reactions = extraData.reactionsById[id])
|
||||
} else {
|
||||
val standardMessage = record.toRemoteStandardMessage(
|
||||
db = db,
|
||||
exportState = exportState,
|
||||
mediaArchiveEnabled = mediaArchiveEnabled,
|
||||
reactionRecords = extraData.reactionsById[id],
|
||||
mentions = extraData.mentionsById[id],
|
||||
|
@ -328,6 +373,7 @@ class ChatItemArchiveExporter(
|
|||
}
|
||||
|
||||
builder.standardMessage = standardMessage
|
||||
transformTimer.emit("standard")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -344,6 +390,7 @@ class ChatItemArchiveExporter(
|
|||
}
|
||||
previousEdits += builder.build()
|
||||
}
|
||||
transformTimer.emit("revisions")
|
||||
}
|
||||
eventTimer.emit("transform")
|
||||
|
||||
|
@ -362,6 +409,8 @@ class ChatItemArchiveExporter(
|
|||
|
||||
override fun close() {
|
||||
Log.d(TAG, "[ChatItemArchiveExporter][batchSize = $batchSize] ${eventTimer.stop().summary}")
|
||||
Log.d(TAG, "[ChatItemArchiveExporterTransform][batchSize = $batchSize] ${transformTimer.stop().summary}")
|
||||
Log.d(TAG, "[ChatItemArchiveExporterExtraData][batchSize = $batchSize] ${extraDataTimer.stop().summary}")
|
||||
}
|
||||
|
||||
private fun readNextMessageRecordBatch(pastIds: Set<Long>): LinkedHashMap<Long, BackupMessageRecord> {
|
||||
|
@ -381,37 +430,39 @@ class ChatItemArchiveExporter(
|
|||
val executor = SignalExecutors.BOUNDED
|
||||
|
||||
val mentionsFuture = executor.submitTyped {
|
||||
db.mentionTable.getMentionsForMessages(messageIds)
|
||||
extraDataTimer.timeEvent("mentions") {
|
||||
db.mentionTable.getMentionsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val reactionsFuture = executor.submitTyped {
|
||||
db.reactionTable.getReactionsForMessages(messageIds)
|
||||
extraDataTimer.timeEvent("reactions") {
|
||||
db.reactionTable.getReactionsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val attachmentsFuture = executor.submitTyped {
|
||||
db.attachmentTable.getAttachmentsForMessages(messageIds)
|
||||
extraDataTimer.timeEvent("attachments") {
|
||||
db.attachmentTable.getAttachmentsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val groupReceiptsFuture = executor.submitTyped {
|
||||
db.groupReceiptTable.getGroupReceiptInfoForMessages(messageIds)
|
||||
}
|
||||
|
||||
val isGroupThreadFuture = executor.submitTyped {
|
||||
db.threadTable.getThreadGroupStatus(messageIds)
|
||||
extraDataTimer.timeEvent("group-receipts") {
|
||||
db.groupReceiptTable.getGroupReceiptInfoForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val mentionsResult = mentionsFuture.get()
|
||||
val reactionsResult = reactionsFuture.get()
|
||||
val attachmentsResult = attachmentsFuture.get()
|
||||
val groupReceiptsResult = groupReceiptsFuture.get()
|
||||
val isGroupThreadResult = isGroupThreadFuture.get()
|
||||
|
||||
return ExtraMessageData(
|
||||
mentionsById = mentionsResult,
|
||||
reactionsById = reactionsResult,
|
||||
attachmentsById = attachmentsResult,
|
||||
groupReceiptsById = groupReceiptsResult,
|
||||
isGroupThreadById = isGroupThreadResult
|
||||
groupReceiptsById = groupReceiptsResult
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -420,9 +471,13 @@ private fun simpleUpdate(type: SimpleChatUpdate.Type): ChatUpdateMessage {
|
|||
return ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = type))
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: RecipientId, isGroupThread: Boolean, groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?, exportState: ExportState, backupStartTime: Long): ChatItem.Builder? {
|
||||
private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: RecipientId, groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?, exportState: ExportState, backupStartTime: Long): ChatItem.Builder? {
|
||||
val record = this
|
||||
|
||||
if (this.threadId !in exportState.threadIds) {
|
||||
return null
|
||||
}
|
||||
|
||||
val direction = when {
|
||||
record.type.isDirectionlessType() && !record.remoteDeleted -> {
|
||||
Direction.DIRECTIONLESS
|
||||
|
@ -443,9 +498,21 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
|||
MessageTypes.isEndSessionType(record.type) && MessageTypes.isOutgoingMessageType(record.type) -> record.toRecipientId
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) && MessageTypes.isOutgoingMessageType(type) -> selfRecipientId.toLong()
|
||||
MessageTypes.isOutgoingAudioCall(type) || MessageTypes.isOutgoingVideoCall(type) -> selfRecipientId.toLong()
|
||||
MessageTypes.isMessageRequestAccepted(type) -> selfRecipientId.toLong()
|
||||
else -> record.fromRecipientId
|
||||
}
|
||||
|
||||
if (!exportState.contactRecipientIds.contains(fromRecipientId)) {
|
||||
Log.w(TAG, ExportSkips.fromRecipientIsNotAnIndividual(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
val threadRecipientId = exportState.threadIdToRecipientId[record.threadId]!!
|
||||
if (exportState.contactRecipientIds.contains(threadRecipientId) && fromRecipientId != threadRecipientId && fromRecipientId != selfRecipientId.toLong()) {
|
||||
Log.w(TAG, ExportSkips.oneOnOneMessageInTheWrongChat(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
val builder = ChatItem.Builder().apply {
|
||||
chatId = record.threadId
|
||||
authorId = fromRecipientId
|
||||
|
@ -460,7 +527,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
|||
}
|
||||
Direction.OUTGOING -> {
|
||||
outgoing = ChatItem.OutgoingMessageDetails(
|
||||
sendStatus = record.toRemoteSendStatus(isGroupThread, groupReceipts, exportState)
|
||||
sendStatus = record.toRemoteSendStatus(isGroupThread = exportState.threadIdToRecipientId[this.chatId] in exportState.groupRecipientIds, groupReceipts = groupReceipts, exportState = exportState)
|
||||
)
|
||||
|
||||
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true) {
|
||||
|
@ -501,13 +568,21 @@ private fun BackupMessageRecord.toRemoteProfileChangeUpdate(): ChatUpdateMessage
|
|||
?: Base64.decodeOrNull(this.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) }
|
||||
|
||||
return if (profileChangeDetails?.profileNameChange != null) {
|
||||
if (profileChangeDetails.profileNameChange.previous.isNotEmpty() && profileChangeDetails.profileNameChange.newValue.isNotEmpty()) {
|
||||
if (profileChangeDetails.profileNameChange.previous.isNotBlank() && profileChangeDetails.profileNameChange.newValue.isNotBlank()) {
|
||||
ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue))
|
||||
} else {
|
||||
Log.w(TAG, ExportSkips.emptyProfileNameChange(this.dateSent))
|
||||
null
|
||||
}
|
||||
} else if (profileChangeDetails?.learnedProfileName != null) {
|
||||
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username))
|
||||
val e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong()
|
||||
val username = profileChangeDetails.learnedProfileName.username
|
||||
if (e164 != null || username.isNotNullOrBlank()) {
|
||||
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = e164, username = username))
|
||||
} else {
|
||||
Log.w(TAG, ExportSkips.emptyLearnedProfileChange(this.dateSent))
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -568,8 +643,8 @@ private fun BackupMessageRecord.toRemoteGroupUpdate(): ChatUpdateMessage? {
|
|||
return null
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteGroupUpdateFromGv1(db: SignalDatabase): ChatUpdateMessage? {
|
||||
val aci = db.recipientTable.getRecord(RecipientId.from(this.fromRecipientId)).aci?.toByteString() ?: return null
|
||||
private fun BackupMessageRecord.toRemoteGroupUpdateFromGv1(exportState: ExportState): ChatUpdateMessage? {
|
||||
val aci = exportState.recipientIdToAci[this.fromRecipientId] ?: return null
|
||||
return ChatUpdateMessage(
|
||||
groupChange = GroupChangeChatUpdate(
|
||||
updates = listOf(
|
||||
|
@ -583,8 +658,8 @@ private fun BackupMessageRecord.toRemoteGroupUpdateFromGv1(db: SignalDatabase):
|
|||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteGroupExpireTimerUpdateFromGv1(db: SignalDatabase): ChatUpdateMessage? {
|
||||
val updater = db.recipientTable.getRecord(RecipientId.from(this.fromRecipientId)).aci?.toByteString() ?: return null
|
||||
private fun BackupMessageRecord.toRemoteGroupExpireTimerUpdateFromGv1(exportState: ExportState): ChatUpdateMessage? {
|
||||
val updater = exportState.recipientIdToAci[this.fromRecipientId] ?: return null
|
||||
return ChatUpdateMessage(
|
||||
groupChange = GroupChangeChatUpdate(
|
||||
updates = listOf(
|
||||
|
@ -599,7 +674,7 @@ private fun BackupMessageRecord.toRemoteGroupExpireTimerUpdateFromGv1(db: Signal
|
|||
)
|
||||
}
|
||||
|
||||
private fun CallTable.Call.toRemoteCallUpdate(db: SignalDatabase, messageRecord: BackupMessageRecord): ChatUpdateMessage? {
|
||||
private fun CallTable.Call.toRemoteCallUpdate(exportState: ExportState, messageRecord: BackupMessageRecord): ChatUpdateMessage? {
|
||||
return when (this.type) {
|
||||
CallTable.Type.GROUP_CALL -> {
|
||||
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(messageRecord.body)
|
||||
|
@ -621,7 +696,7 @@ private fun CallTable.Call.toRemoteCallUpdate(db: SignalDatabase, messageRecord:
|
|||
CallTable.Event.DELETE -> return null
|
||||
},
|
||||
ringerRecipientId = this.ringerRecipient?.toLong(),
|
||||
startedCallRecipientId = ACI.parseOrNull(groupCallUpdateDetails.startedCallUuid)?.let { db.recipientTable.getByAci(it).getOrNull()?.toLong() },
|
||||
startedCallRecipientId = groupCallUpdateDetails.startedCallUuid.takeIf { it.isNotEmpty() }?.let { exportState.aciToRecipientId[it] },
|
||||
startedCallTimestamp = this.timestamp.clampToValidBackupRange(),
|
||||
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp.clampToValidBackupRange().takeIf { it > 0 },
|
||||
read = messageRecord.read
|
||||
|
@ -871,11 +946,11 @@ private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(mediaArchiveEnab
|
|||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteStandardMessage(db: SignalDatabase, mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
|
||||
private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState, mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
|
||||
val text = body.nullIfBlank()?.let {
|
||||
Text(
|
||||
body = it,
|
||||
bodyRanges = (this.bodyRanges?.toRemoteBodyRanges(this.dateSent) ?: emptyList()) + (mentions?.toRemoteBodyRanges(db) ?: emptyList())
|
||||
bodyRanges = (this.bodyRanges?.toRemoteBodyRanges(this.dateSent) ?: emptyList()) + (mentions?.toRemoteBodyRanges(exportState) ?: emptyList())
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -917,21 +992,28 @@ private fun BackupMessageRecord.toRemoteQuote(mediaArchiveEnabled: Boolean, atta
|
|||
}
|
||||
|
||||
val bodyRanges = this.quoteBodyRanges?.toRemoteBodyRanges(dateSent) ?: emptyList()
|
||||
val body = this.quoteBody?.takeUnless { it.isBlank() }?.let { body ->
|
||||
Text(
|
||||
body = body,
|
||||
bodyRanges = bodyRanges
|
||||
)
|
||||
}
|
||||
val attachments = if (remoteType == Quote.Type.VIEW_ONCE) {
|
||||
emptyList()
|
||||
} else {
|
||||
attachments?.toRemoteQuoteAttachments(mediaArchiveEnabled) ?: emptyList()
|
||||
}
|
||||
|
||||
if (remoteType == Quote.Type.NORMAL && body == null && attachments.isEmpty()) {
|
||||
Log.w(TAG, ExportOddities.emptyQuote(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
return Quote(
|
||||
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID }?.clampToValidBackupRange(),
|
||||
authorId = this.quoteAuthor,
|
||||
text = this.quoteBody?.let { body ->
|
||||
Text(
|
||||
body = body,
|
||||
bodyRanges = bodyRanges
|
||||
)
|
||||
},
|
||||
attachments = if (remoteType == Quote.Type.VIEW_ONCE) {
|
||||
emptyList()
|
||||
} else {
|
||||
attachments?.toRemoteQuoteAttachments(mediaArchiveEnabled) ?: emptyList()
|
||||
},
|
||||
text = body,
|
||||
attachments = attachments,
|
||||
type = remoteType
|
||||
)
|
||||
}
|
||||
|
@ -1023,12 +1105,12 @@ private fun List<DatabaseAttachment>.toRemoteAttachments(mediaArchiveEnabled: Bo
|
|||
}
|
||||
}
|
||||
|
||||
private fun List<Mention>.toRemoteBodyRanges(db: SignalDatabase): List<BackupBodyRange> {
|
||||
private fun List<Mention>.toRemoteBodyRanges(exportState: ExportState): List<BackupBodyRange> {
|
||||
return this.map {
|
||||
BackupBodyRange(
|
||||
start = it.start,
|
||||
length = it.length,
|
||||
mentionAci = db.recipientTable.getRecord(it.recipientId).aci?.toByteString()
|
||||
mentionAci = exportState.recipientIdToAci[it.recipientId.toLong()]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1431,8 +1513,7 @@ private data class ExtraMessageData(
|
|||
val mentionsById: Map<Long, List<Mention>>,
|
||||
val reactionsById: Map<Long, List<ReactionRecord>>,
|
||||
val attachmentsById: Map<Long, List<DatabaseAttachment>>,
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>,
|
||||
val isGroupThreadById: Map<Long, Boolean>
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>
|
||||
)
|
||||
|
||||
private enum class Direction {
|
||||
|
|
|
@ -56,6 +56,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso
|
|||
group = ArchiveGroup(
|
||||
masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(),
|
||||
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
blocked = cursor.requireBoolean(RecipientTable.BLOCKED),
|
||||
hideStory = extras?.hideStory() ?: false,
|
||||
storySendMode = showAsStoryState.toRemote(),
|
||||
snapshot = decryptedGroup.toRemote(isActive, selfAci)
|
||||
|
|
|
@ -119,7 +119,8 @@ class ChatItemArchiveImporter(
|
|||
MessageTable.MESSAGE_EXTRAS,
|
||||
MessageTable.ORIGINAL_MESSAGE_ID,
|
||||
MessageTable.LATEST_REVISION_ID,
|
||||
MessageTable.PARENT_STORY_ID
|
||||
MessageTable.PARENT_STORY_ID,
|
||||
MessageTable.NOTIFIED
|
||||
)
|
||||
|
||||
private val REACTION_COLUMNS = arrayOf(
|
||||
|
|
|
@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.importer
|
|||
|
||||
import android.content.ContentValues
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
|
||||
import org.signal.storageservice.protos.groups.AccessControl
|
||||
|
@ -51,7 +52,8 @@ object GroupArchiveImporter {
|
|||
val values = ContentValues().apply {
|
||||
put(RecipientTable.GROUP_ID, groupId.toString())
|
||||
put(RecipientTable.AVATAR_COLOR, AvatarColorHash.forGroupId(groupId).serialize())
|
||||
put(RecipientTable.PROFILE_SHARING, group.whitelisted)
|
||||
put(RecipientTable.PROFILE_SHARING, group.whitelisted.toInt())
|
||||
put(RecipientTable.BLOCKED, group.blocked.toInt())
|
||||
put(RecipientTable.TYPE, RecipientTable.RecipientType.GV2.id)
|
||||
put(RecipientTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
|
||||
if (group.hideStory) {
|
||||
|
|
|
@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.backup.v2.processor
|
|||
import android.content.Context
|
||||
import okio.ByteString.Companion.EMPTY
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
|
@ -68,7 +69,7 @@ object AccountDataArchiveProcessor {
|
|||
familyName = selfRecord.signalProfileName.familyName,
|
||||
avatarUrlPath = selfRecord.signalProfileAvatar ?: "",
|
||||
username = selfRecord.username?.takeIf { it.isNotBlank() },
|
||||
usernameLink = if (signalStore.accountValues.usernameLink != null) {
|
||||
usernameLink = if (selfRecord.username.isNotNullOrBlank() && signalStore.accountValues.usernameLink != null) {
|
||||
AccountData.UsernameLink(
|
||||
entropy = signalStore.accountValues.usernameLink?.entropy?.toByteString() ?: EMPTY,
|
||||
serverId = signalStore.accountValues.usernameLink?.serverId?.toByteArray()?.toByteString() ?: EMPTY,
|
||||
|
|
|
@ -32,6 +32,7 @@ object ChatArchiveProcessor {
|
|||
for (chat in reader) {
|
||||
if (exportState.recipientIds.contains(chat.recipientId)) {
|
||||
exportState.threadIds.add(chat.id)
|
||||
exportState.threadIdToRecipientId[chat.id] = chat.recipientId
|
||||
emitter.emit(Frame(chat = chat))
|
||||
} else {
|
||||
Log.w(TAG, "dropping thread for deleted recipient ${chat.recipientId}")
|
||||
|
|
|
@ -35,9 +35,15 @@ object RecipientArchiveProcessor {
|
|||
val TAG = Log.tag(RecipientArchiveProcessor::class.java)
|
||||
|
||||
fun export(db: SignalDatabase, signalStore: SignalStore, exportState: ExportState, selfRecipientId: RecipientId, selfAci: ServiceId.ACI, emitter: BackupFrameEmitter) {
|
||||
exportState.recipientIds.add(selfRecipientId.toLong())
|
||||
exportState.contactRecipientIds.add(selfRecipientId.toLong())
|
||||
exportState.recipientIdToAci[selfRecipientId.toLong()] = selfAci.toByteString()
|
||||
exportState.aciToRecipientId[selfAci.toString()] = selfRecipientId.toLong()
|
||||
|
||||
val releaseChannelId = signalStore.releaseChannelValues.releaseChannelRecipientId
|
||||
if (releaseChannelId != null) {
|
||||
exportState.recipientIds.add(releaseChannelId.toLong())
|
||||
exportState.contactRecipientIds.add(releaseChannelId.toLong())
|
||||
emitter.emit(
|
||||
Frame(
|
||||
recipient = ArchiveRecipient(
|
||||
|
@ -54,6 +60,12 @@ object RecipientArchiveProcessor {
|
|||
for (recipient in reader) {
|
||||
if (recipient != null) {
|
||||
exportState.recipientIds.add(recipient.id)
|
||||
exportState.contactRecipientIds.add(recipient.id)
|
||||
recipient.contact?.aci?.let {
|
||||
exportState.recipientIdToAci[recipient.id] = it
|
||||
exportState.aciToRecipientId[ServiceId.ACI.parseOrThrow(it).toString()] = recipient.id
|
||||
}
|
||||
|
||||
emitter.emit(Frame(recipient = recipient))
|
||||
}
|
||||
}
|
||||
|
@ -62,6 +74,7 @@ object RecipientArchiveProcessor {
|
|||
db.recipientTable.getGroupsForBackup(selfAci).use { reader ->
|
||||
for (recipient in reader) {
|
||||
exportState.recipientIds.add(recipient.id)
|
||||
exportState.groupRecipientIds.add(recipient.id)
|
||||
emitter.emit(Frame(recipient = recipient))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ fun MessageBackupsEducationScreen(
|
|||
) {
|
||||
Scaffolds.Settings(
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationContentDescription = stringResource(android.R.string.cancel),
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24),
|
||||
title = ""
|
||||
) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.app.Activity
|
|||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
@ -20,12 +21,14 @@ import androidx.navigation.compose.composable
|
|||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlinx.coroutines.rx3.asFlowable
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.Nav
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
|
@ -36,7 +39,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
|||
|
||||
companion object {
|
||||
|
||||
private const val TIER = "tier"
|
||||
@VisibleForTesting
|
||||
const val TIER = "tier"
|
||||
|
||||
fun create(messageBackupTier: MessageBackupTier?): MessageBackupsFlowFragment {
|
||||
return MessageBackupsFlowFragment().apply {
|
||||
|
@ -88,7 +92,9 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
|||
MessageBackupsEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onEnableBackups = viewModel::goToNextStage,
|
||||
onLearnMore = {}
|
||||
onLearnMore = {
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.backup_support_url))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
@ -33,6 +34,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
@ -70,6 +72,10 @@ fun MessageBackupsKeyRecordScreen(
|
|||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
val backupKeyString = remember(backupKey) {
|
||||
backupKey.chunked(4).joinToString(" ")
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
|
@ -82,67 +88,79 @@ fun MessageBackupsKeyRecordScreen(
|
|||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_backups_lock),
|
||||
contentDescription = null,
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.size(80.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
val backupKeyString = remember(backupKey) {
|
||||
backupKey.chunked(4).joinToString(" ")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp, bottom = 16.dp)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
.weight(1f)
|
||||
.testTag("message-backups-key-record-screen-lazy-column")
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_backups_lock),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.size(80.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = backupKeyString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight(400),
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.Small(
|
||||
onClick = { onCopyToClipboardClick(backupKeyString) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
)
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp, bottom = 16.dp)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = backupKeyString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight(400),
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.Small(
|
||||
onClick = { onCopyToClipboardClick(backupKeyString) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
|
@ -189,66 +207,80 @@ private fun BottomSheetContent(
|
|||
) {
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
.testTag("message-backups-key-record-screen-sheet-content")
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 30.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.clip(shape = RoundedCornerShape(percent = 50))
|
||||
.clickable(onClick = { checked = !checked })
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { checked = it }
|
||||
)
|
||||
item {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 30.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = checked,
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSeeKeyAgainClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
|
||||
)
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.clip(shape = RoundedCornerShape(percent = 50))
|
||||
.clickable(onClick = { checked = !checked })
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { checked = it }
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.LargeTonal(
|
||||
enabled = checked,
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = onSeeKeyAgainClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
|
@ -93,6 +94,7 @@ fun MessageBackupsTypeSelectionScreen(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.testTag("message-backups-type-selection-screen-lazy-column")
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
|
|
|
@ -37,7 +37,7 @@ object IBANVisualTransformation : VisualTransformation {
|
|||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
return offset - (offset / 4)
|
||||
return offset - (offset / 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -840,48 +840,51 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
|||
}
|
||||
)
|
||||
|
||||
val reportSpamTint = if (state.isDeprecatedOrUnregistered) R.color.signal_alert_primary_50 else R.color.signal_alert_primary
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
BlockUnblockDialog.showReportSpamFor(
|
||||
requireContext(),
|
||||
viewLifecycleOwner.lifecycle,
|
||||
state.recipient,
|
||||
{
|
||||
viewModel
|
||||
.onReportSpam()
|
||||
.subscribeBy {
|
||||
Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show()
|
||||
onToolbarNavigationClicked()
|
||||
}
|
||||
.addTo(lifecycleDisposable)
|
||||
},
|
||||
if (state.recipient.isBlocked) {
|
||||
null
|
||||
} else {
|
||||
Runnable {
|
||||
if (!state.recipient.isReleaseNotes) {
|
||||
val reportSpamTint = if (state.isDeprecatedOrUnregistered) R.color.signal_alert_primary_50 else R.color.signal_alert_primary
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
BlockUnblockDialog.showReportSpamFor(
|
||||
requireContext(),
|
||||
viewLifecycleOwner.lifecycle,
|
||||
state.recipient,
|
||||
{
|
||||
viewModel
|
||||
.onBlockAndReportSpam()
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam_and_blocked, Toast.LENGTH_SHORT).show()
|
||||
onToolbarNavigationClicked()
|
||||
}
|
||||
is Result.Failure -> {
|
||||
Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(result.failure), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.onReportSpam()
|
||||
.subscribeBy {
|
||||
Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show()
|
||||
onToolbarNavigationClicked()
|
||||
}
|
||||
.addTo(lifecycleDisposable)
|
||||
},
|
||||
if (state.recipient.isBlocked) {
|
||||
null
|
||||
} else {
|
||||
Runnable {
|
||||
viewModel
|
||||
.onBlockAndReportSpam()
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam_and_blocked, Toast.LENGTH_SHORT).show()
|
||||
onToolbarNavigationClicked()
|
||||
}
|
||||
|
||||
is Result.Failure -> {
|
||||
Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(result.failure), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.addTo(lifecycleDisposable)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -319,6 +319,10 @@ class ControlsAndInfoController private constructor(
|
|||
|
||||
showOrHideControlsOnUpdate(previousState)
|
||||
|
||||
if (controlState == WebRtcControls.PIP) {
|
||||
waitingToBeLetIn.visible = false
|
||||
}
|
||||
|
||||
if (controlState != WebRtcControls.PIP && controlState.controlVisibilitiesChanged(previousState)) {
|
||||
updateControlVisibilities()
|
||||
}
|
||||
|
|
|
@ -5,9 +5,8 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.fragment.compose.content
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
@ -16,16 +15,11 @@ import org.thoughtcrime.securesms.util.DynamicTheme
|
|||
* Generic ComposeFragment which can be subclassed to build UI with compose.
|
||||
*/
|
||||
abstract class ComposeFragment : LoggingFragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
SignalTheme(
|
||||
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
FragmentContent()
|
||||
}
|
||||
}
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = content {
|
||||
SignalTheme(
|
||||
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
FragmentContent()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2344,7 +2344,7 @@ class ConversationFragment :
|
|||
|
||||
val attachments = SaveAttachmentUtil.getAttachmentsForRecord(record)
|
||||
|
||||
SaveAttachmentUtil.showWarningDialogIfNecessary(requireContext()) {
|
||||
SaveAttachmentUtil.showWarningDialogIfNecessary(requireContext(), attachments.size) {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
performAttachmentSave(attachments)
|
||||
} else {
|
||||
|
@ -2433,13 +2433,14 @@ class ConversationFragment :
|
|||
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
|
||||
val recipient = viewModel.recipientSnapshot ?: return false
|
||||
|
||||
return actionMode == null && MenuState.canReplyToMessage(
|
||||
recipient,
|
||||
MenuState.isActionMessage(conversationMessage.messageRecord),
|
||||
conversationMessage.messageRecord,
|
||||
viewModel.hasMessageRequestState,
|
||||
conversationGroupViewModel.isNonAdminInAnnouncementGroup()
|
||||
)
|
||||
return actionMode == null &&
|
||||
MenuState.canReplyToMessage(
|
||||
recipient,
|
||||
MenuState.isActionMessage(conversationMessage.messageRecord),
|
||||
conversationMessage.messageRecord,
|
||||
viewModel.hasMessageRequestState,
|
||||
conversationGroupViewModel.isNonAdminInAnnouncementGroup()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3845,7 +3846,10 @@ class ConversationFragment :
|
|||
|
||||
//region Compose + Send Callbacks
|
||||
|
||||
private inner class SendButtonListener : View.OnClickListener, OnEditorActionListener, SendButton.ScheduledSendListener {
|
||||
private inner class SendButtonListener :
|
||||
View.OnClickListener,
|
||||
OnEditorActionListener,
|
||||
SendButton.ScheduledSendListener {
|
||||
override fun onClick(v: View) {
|
||||
sendMessage()
|
||||
}
|
||||
|
@ -4172,7 +4176,10 @@ class ConversationFragment :
|
|||
override fun create(): Fragment = KeyboardPagerFragment()
|
||||
}
|
||||
|
||||
private inner class KeyboardEvents : OnBackPressedCallback(false), InputAwareConstraintLayout.Listener, InsetAwareConstraintLayout.KeyboardStateListener {
|
||||
private inner class KeyboardEvents :
|
||||
OnBackPressedCallback(false),
|
||||
InputAwareConstraintLayout.Listener,
|
||||
InsetAwareConstraintLayout.KeyboardStateListener {
|
||||
override fun handleOnBackPressed() {
|
||||
container.hideInput()
|
||||
}
|
||||
|
|
|
@ -20,7 +20,13 @@ class InputReadyState(
|
|||
val isClientExpired: Boolean,
|
||||
val isUnauthorized: Boolean,
|
||||
) {
|
||||
private val selfMemberLevel: GroupTable.MemberLevel? = groupRecord?.let { if (it.isActive) it.memberLevel(Recipient.self()) else GroupTable.MemberLevel.NOT_A_MEMBER }
|
||||
private val selfMemberLevel: GroupTable.MemberLevel? = groupRecord?.let {
|
||||
val level = it.memberLevel(Recipient.self())
|
||||
if (!it.isActive && level == GroupTable.MemberLevel.FULL_MEMBER) {
|
||||
GroupTable.MemberLevel.NOT_A_MEMBER
|
||||
}
|
||||
level
|
||||
}
|
||||
|
||||
val isAnnouncementGroup: Boolean? = groupRecord?.isAnnouncementGroup
|
||||
val isActiveGroup: Boolean? = if (selfMemberLevel == null) null else selfMemberLevel != GroupTable.MemberLevel.NOT_A_MEMBER
|
||||
|
|
|
@ -3506,44 +3506,47 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
|||
}
|
||||
|
||||
fun deleteMessagesInThread(threadIds: Collection<Long>, extraWhere: String = ""): Int {
|
||||
var deletedCount = 0
|
||||
var totalDeletedCount = 0
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
SignalDatabase.messageSearch.dropAfterMessageDeleteTrigger()
|
||||
SignalDatabase.messageLog.dropAfterMessageDeleteTrigger()
|
||||
|
||||
for (threadId in threadIds) {
|
||||
val subSelect = "SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere"
|
||||
val subSelect = "SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere LIMIT 1000"
|
||||
do {
|
||||
// Bulk deleting FK tables for large message delete efficiency
|
||||
db.delete(StorySendTable.TABLE_NAME)
|
||||
.where("${StorySendTable.TABLE_NAME}.${StorySendTable.MESSAGE_ID} IN ($subSelect)")
|
||||
.run()
|
||||
|
||||
// Bulk deleting FK tables for large message delete efficiency
|
||||
db.delete(StorySendTable.TABLE_NAME)
|
||||
.where("${StorySendTable.TABLE_NAME}.${StorySendTable.MESSAGE_ID} IN ($subSelect)")
|
||||
.run()
|
||||
db.delete(ReactionTable.TABLE_NAME)
|
||||
.where("${ReactionTable.TABLE_NAME}.${ReactionTable.MESSAGE_ID} IN ($subSelect)")
|
||||
.run()
|
||||
|
||||
db.delete(ReactionTable.TABLE_NAME)
|
||||
.where("${ReactionTable.TABLE_NAME}.${ReactionTable.MESSAGE_ID} IN ($subSelect)")
|
||||
.run()
|
||||
db.delete(CallTable.TABLE_NAME)
|
||||
.where("${CallTable.TABLE_NAME}.${CallTable.MESSAGE_ID} IN ($subSelect)")
|
||||
.run()
|
||||
|
||||
db.delete(CallTable.TABLE_NAME)
|
||||
.where("${CallTable.TABLE_NAME}.${CallTable.MESSAGE_ID} IN ($subSelect)")
|
||||
.run()
|
||||
// Must delete rows from FTS table before deleting from main table due to FTS requirement when deleting by rowid
|
||||
db.delete(SearchTable.FTS_TABLE_NAME)
|
||||
.where("${SearchTable.FTS_TABLE_NAME}.${SearchTable.ID} IN ($subSelect)")
|
||||
.run()
|
||||
|
||||
// Must delete rows from FTS table before deleting from main table due to FTS requirement when deleting by rowid
|
||||
db.delete(SearchTable.FTS_TABLE_NAME)
|
||||
.where("${SearchTable.FTS_TABLE_NAME}.${SearchTable.ID} IN ($subSelect)")
|
||||
.run()
|
||||
// Actually delete messages
|
||||
val deletedCount = db.delete(TABLE_NAME)
|
||||
.where("$ID IN ($subSelect)")
|
||||
.run()
|
||||
|
||||
// Actually delete messages
|
||||
deletedCount += db.delete(TABLE_NAME)
|
||||
.where("$THREAD_ID = ? $extraWhere", threadId)
|
||||
.run()
|
||||
totalDeletedCount += deletedCount
|
||||
} while (deletedCount > 0)
|
||||
}
|
||||
|
||||
SignalDatabase.messageSearch.restoreAfterMessageDeleteTrigger()
|
||||
SignalDatabase.messageLog.restoreAfterMessageDeleteTrigger()
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
return totalDeletedCount
|
||||
}
|
||||
|
||||
fun deleteAbandonedMessages(threadId: Long? = null): Int {
|
||||
|
|
|
@ -1942,11 +1942,9 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
|||
SNIPPET_EXTRAS to null,
|
||||
SNIPPET_MESSAGE_EXTRAS to null,
|
||||
UNREAD_COUNT to 0,
|
||||
ARCHIVED to 0,
|
||||
STATUS to 0,
|
||||
HAS_DELIVERY_RECEIPT to 0,
|
||||
HAS_READ_RECEIPT to 0,
|
||||
EXPIRES_IN to 0,
|
||||
LAST_SEEN to 0,
|
||||
HAS_SENT to 0,
|
||||
LAST_SCROLLED to 0,
|
||||
|
|
|
@ -93,13 +93,22 @@ public class GroupCallUpdateMessageFactory implements UpdateDescription.Spannabl
|
|||
: context.getString(R.string.MessageRecord_s_is_in_the_call, describe(joinedMembers.get(0)));
|
||||
}
|
||||
case 2:
|
||||
return withTime ? context.getString(R.string.MessageRecord_s_and_s_are_in_the_call_s1,
|
||||
describe(joinedMembers.get(0)),
|
||||
describe(joinedMembers.get(1)),
|
||||
time)
|
||||
: context.getString(R.string.MessageRecord_s_and_s_are_in_the_call,
|
||||
describe(joinedMembers.get(0)),
|
||||
describe(joinedMembers.get(1)));
|
||||
boolean includesSelf = joinedMembers.contains(selfAci);
|
||||
if (includesSelf) {
|
||||
return withTime ? context.getString(R.string.MessageRecord_s_and_you_are_in_the_call_s1,
|
||||
describe(joinedMembers.get(0)),
|
||||
time)
|
||||
: context.getString(R.string.MessageRecord_s_and_you_are_in_the_call,
|
||||
describe(joinedMembers.get(0)));
|
||||
} else {
|
||||
return withTime ? context.getString(R.string.MessageRecord_s_and_s_are_in_the_call_s1,
|
||||
describe(joinedMembers.get(0)),
|
||||
describe(joinedMembers.get(1)),
|
||||
time)
|
||||
: context.getString(R.string.MessageRecord_s_and_s_are_in_the_call,
|
||||
describe(joinedMembers.get(0)),
|
||||
describe(joinedMembers.get(1)));
|
||||
}
|
||||
default:
|
||||
int others = joinedMembers.size() - 2;
|
||||
return withTime ? context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_call_s,
|
||||
|
|
|
@ -20,6 +20,7 @@ class DeleteAbandonedAttachmentsJob private constructor(parameters: Parameters)
|
|||
private val TAG = Log.tag(DeleteAbandonedAttachmentsJob::class)
|
||||
const val KEY = "DeleteAbandonedAttachmentsJob"
|
||||
|
||||
@JvmStatic
|
||||
fun enqueue() {
|
||||
AppDependencies.jobManager.add(DeleteAbandonedAttachmentsJob())
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ class InAppPaymentRecurringContextJob private constructor(
|
|||
|
||||
const val KEY = "InAppPurchaseRecurringContextJob"
|
||||
|
||||
fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job {
|
||||
fun create(inAppPayment: InAppPaymentTable.InAppPayment): InAppPaymentRecurringContextJob {
|
||||
return InAppPaymentRecurringContextJob(
|
||||
inAppPaymentId = inAppPayment.id,
|
||||
parameters = Parameters.Builder()
|
||||
|
@ -443,7 +443,6 @@ class InAppPaymentRecurringContextJob private constructor(
|
|||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
serviceResponse: ServiceResponse<ReceiptCredentialResponse>
|
||||
) {
|
||||
val isForKeepAlive = inAppPayment.data.redemption!!.keepAlive == true
|
||||
val applicationError = serviceResponse.applicationError.get()
|
||||
when (serviceResponse.status) {
|
||||
204 -> {
|
||||
|
@ -482,6 +481,7 @@ class InAppPaymentRecurringContextJob private constructor(
|
|||
}
|
||||
|
||||
updateInAppPaymentWithTokenAlreadyRedeemedError(inAppPayment)
|
||||
throw Exception(applicationError)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
|
|
@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.migrations.CopyUsernameToSignalStoreMigrationJ
|
|||
import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DuplicateE164MigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.EmojiDownloadMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.EmojiSearchIndexCheckMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.GooglePlayBillingPurchaseTokenMigrationJob;
|
||||
|
@ -283,6 +284,7 @@ public final class JobManagerFactories {
|
|||
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
|
||||
put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory());
|
||||
put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory());
|
||||
put(DuplicateE164MigrationJob.KEY, new DuplicateE164MigrationJob.Factory());
|
||||
put(EmojiDownloadMigrationJob.KEY, new EmojiDownloadMigrationJob.Factory());
|
||||
put(EmojiSearchIndexCheckMigrationJob.KEY, new EmojiSearchIndexCheckMigrationJob.Factory());
|
||||
put(GooglePlayBillingPurchaseTokenMigrationJob.KEY, new GooglePlayBillingPurchaseTokenMigrationJob.Factory());
|
||||
|
|
|
@ -74,7 +74,7 @@ class NewLinkedDeviceNotificationJob private constructor(
|
|||
val builder = NotificationCompat.Builder(context, NotificationChannels.getInstance().NEW_LINKED_DEVICE)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.NewLinkedDeviceNotification__you_linked_new_device))
|
||||
.setContentText(context.getString(R.string.NewLinkedDeviceNotification__a_new_device_was_linked, DateUtils.getOnlyTimeString(context, data.deviceCreatedAt)))
|
||||
.setContentText(context.getString(R.string.NewLinkedDeviceNotification__a_new_device_was_linked, DateUtils.getOnlyTimeAtString(context, data.deviceCreatedAt)))
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.NEW_LINKED_DEVICE, builder.build())
|
||||
|
|
|
@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.RestoreAttachmentConstraint
|
|||
import org.thoughtcrime.securesms.jobs.protos.RestoreAttachmentJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.MmsException
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
|
@ -140,10 +139,6 @@ class RestoreAttachmentJob private constructor(
|
|||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (!SignalDatabase.messages.isStory(messageId)) {
|
||||
AppDependencies.messageNotifier.updateNotification(context, forConversation(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, RetryLaterException::class)
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.jobs
|
|||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.logging.logI
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
|
@ -84,6 +85,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
|
|||
val newContactStorageIds = generateContactStorageIds(oldContactStorageIds)
|
||||
val inserts: MutableList<SignalStorageRecord> = oldContactStorageIds.keys
|
||||
.mapNotNull { SignalDatabase.recipients.getRecordForSync(it) }
|
||||
.filter { it.recipientType != RecipientTable.RecipientType.INDIVIDUAL || (it.aci != null || it.pni != null || it.e164 != null) }
|
||||
.map { record -> StorageSyncModels.localToRemoteRecord(record, newContactStorageIds[record.id]!!.raw) }
|
||||
.toMutableList()
|
||||
|
||||
|
|
|
@ -80,6 +80,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
|
|||
private const val KEY_HAS_LINKED_DEVICES = "account.has_linked_devices"
|
||||
|
||||
private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool"
|
||||
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool"
|
||||
|
||||
private val AEP_LOCK = ReentrantLock()
|
||||
}
|
||||
|
@ -133,16 +134,28 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
|
|||
|
||||
fun restoreAccountEntropyPool(aep: AccountEntropyPool) {
|
||||
AEP_LOCK.withLock {
|
||||
store.beginWrite().putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value).commit()
|
||||
store
|
||||
.beginWrite()
|
||||
.putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value)
|
||||
.putBoolean(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, true)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAccountEntropyPool() {
|
||||
AEP_LOCK.withLock {
|
||||
store.beginWrite().putString(KEY_ACCOUNT_ENTROPY_POOL, null).commit()
|
||||
Log.i(TAG, "Resetting Account Entropy Pool (AEP)", Throwable())
|
||||
store
|
||||
.beginWrite()
|
||||
.putString(KEY_ACCOUNT_ENTROPY_POOL, null)
|
||||
.putBoolean(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, false)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val restoredAccountEntropyPool by booleanValue(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, false)
|
||||
|
||||
/** The local user's [ACI]. */
|
||||
val aci: ACI?
|
||||
get() = ACI.parseOrNull(getString(KEY_ACI, null))
|
||||
|
|
|
@ -72,6 +72,10 @@ public class EmojiValues extends SignalStoreValues {
|
|||
return getString(PREFIX + canonical, emoji);
|
||||
}
|
||||
|
||||
public void removePreferredVariation(@NonNull String emoji) {
|
||||
getStore().beginWrite().remove(PREFIX + emoji).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list usable emoji that the user has selected as their defaults. If any stored reactions are unreadable, it will provide a default.
|
||||
* For raw access to the unfiltered list of reactions, see {@link #getRawReactions()}.
|
||||
|
|
|
@ -88,7 +88,9 @@ public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Auth
|
|||
|
||||
return result;
|
||||
} catch (IOException | InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
Log.w(TAG, "Unable to read cached credentials, clearing and requesting new ones instead", e);
|
||||
clear();
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
private const val SVR2_AUTH_TOKENS = "kbs.kbs_auth_tokens"
|
||||
private const val SVR_LAST_AUTH_REFRESH_TIMESTAMP = "kbs.kbs_auth_tokens.last_refresh_timestamp"
|
||||
private const val SVR3_AUTH_TOKENS = "kbs.svr3_auth_tokens"
|
||||
private const val RESTORED_VIA_ACCOUNT_ENTROPY_KEY = "kbs.restore_via_account_entropy_pool"
|
||||
private const val INITIAL_RESTORE_MASTER_KEY = "kbs.initialRestoreMasterKey"
|
||||
}
|
||||
|
||||
|
@ -145,7 +144,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
|
||||
@Synchronized
|
||||
fun hasOptedInWithAccess(): Boolean {
|
||||
return hasPin() || restoredViaAccountEntropyPool || SignalStore.account.isLinkedDevice
|
||||
return hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.isLinkedDevice
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
@ -153,9 +152,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
return localPinHash != null
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val restoredViaAccountEntropyPool by booleanValue(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, false)
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isPinForgottenOrSkipped: Boolean by booleanValue(PIN_FORGOTTEN_OR_SKIPPED, false)
|
||||
|
@ -242,7 +238,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
.putBoolean(OPTED_OUT, true)
|
||||
.remove(LOCK_LOCAL_PIN_HASH)
|
||||
.remove(PIN)
|
||||
.remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
|
||||
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
|
||||
.commit()
|
||||
}
|
||||
|
|
|
@ -406,9 +406,10 @@ class LinkDeviceViewModel : ViewModel() {
|
|||
private fun Uri.supportsLinkAndSync(): Boolean {
|
||||
return if (RemoteConfig.internalUser) {
|
||||
this.getQueryParameter("capabilities")?.split(",")?.contains("backup") == true ||
|
||||
this.getQueryParameter("capabilities")?.split(",")?.contains("backup2") == true
|
||||
this.getQueryParameter("capabilities")?.split(",")?.contains("backup2") == true ||
|
||||
this.getQueryParameter("capabilities")?.split(",")?.contains("backup3") == true
|
||||
} else {
|
||||
this.getQueryParameter("capabilities")?.split(",")?.contains("backup") == true
|
||||
this.getQueryParameter("capabilities")?.split(",")?.contains("backup3") == true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ public class LogSectionPin implements LogSection {
|
|||
.append("Next Reminder Interval: ").append(SignalStore.pin().getCurrentInterval()).append("\n")
|
||||
.append("Reglock: ").append(SignalStore.svr().isRegistrationLockEnabled()).append("\n")
|
||||
.append("Signal PIN: ").append(SignalStore.svr().hasPin()).append("\n")
|
||||
.append("Restored via AEP: ").append(SignalStore.svr().getRestoredViaAccountEntropyPool()).append("\n")
|
||||
.append("Restored via AEP: ").append(SignalStore.account().getRestoredAccountEntropyPool()).append("\n")
|
||||
.append("Opted Out: ").append(SignalStore.svr().hasOptedOut()).append("\n")
|
||||
.append("Last Creation Failed: ").append(SignalStore.svr().lastPinCreateFailed()).append("\n")
|
||||
.append("Needs Account Restore: ").append(SignalStore.storageService().getNeedsAccountRestore()).append("\n")
|
||||
|
|
|
@ -44,7 +44,7 @@ final class MediaActions {
|
|||
return;
|
||||
}
|
||||
|
||||
SaveAttachmentTask.showWarningDialogIfNecessary(context, () -> Permissions.with(fragment)
|
||||
SaveAttachmentTask.showWarningDialogIfNecessary(context, mediaRecords.size(), () -> Permissions.with(fragment)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(fragment.getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
|
|
|
@ -78,7 +78,9 @@ import java.util.Locale
|
|||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v2), MediaPreviewFragment.Events {
|
||||
class MediaPreviewV2Fragment :
|
||||
LoggingFragment(R.layout.fragment_media_preview_v2),
|
||||
MediaPreviewFragment.Events {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val binding by ViewBinderDelegate(FragmentMediaPreviewV2Binding::bind)
|
||||
|
@ -557,7 +559,7 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v
|
|||
}
|
||||
|
||||
private fun saveToDisk(mediaItem: MediaTable.MediaRecord) {
|
||||
SaveAttachmentTask.showWarningDialogIfNecessary(requireContext()) {
|
||||
SaveAttachmentTask.showWarningDialogIfNecessary(requireContext(), 1) {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
performSaveToDisk(mediaItem)
|
||||
return@showWarningDialogIfNecessary
|
||||
|
|
|
@ -340,7 +340,7 @@ public final class Megaphones {
|
|||
}
|
||||
|
||||
private static @NonNull Megaphone buildNewLinkedDeviceMegaphone(@NonNull Context context) {
|
||||
String createdAt = DateUtils.getOnlyTimeString(context, SignalStore.misc().getNewLinkedDeviceCreatedTime());
|
||||
String createdAt = DateUtils.getOnlyTimeAtString(context, SignalStore.misc().getNewLinkedDeviceCreatedTime());
|
||||
return new Megaphone.Builder(Event.NEW_LINKED_DEVICE, Megaphone.Style.BASIC)
|
||||
.setTitle(R.string.NewLinkedDeviceNotification__you_linked_new_device)
|
||||
.setBody(context.getString(R.string.NewLinkedDeviceMegaphone__a_new_device_was_linked, createdAt))
|
||||
|
|
|
@ -85,6 +85,7 @@ class IncomingMessageObserver(private val context: Application, private val sign
|
|||
private val connectionNecessarySemaphore = Semaphore(0)
|
||||
private val networkConnectionListener = NetworkConnectionListener(context) { isNetworkUnavailable ->
|
||||
lock.withLock {
|
||||
AppDependencies.libsignalNetwork.onNetworkChange()
|
||||
if (isNetworkUnavailable()) {
|
||||
Log.w(TAG, "Lost network connection. Shutting down our websocket connections and resetting the drained state.")
|
||||
decryptionDrained = false
|
||||
|
|
|
@ -157,10 +157,11 @@ public class ApplicationMigrations {
|
|||
static final int GPB_TOKEN_MIGRATION = 124;
|
||||
static final int GROUP_ADD_MIGRATION = 125;
|
||||
static final int SSRE2_CAPABILITY = 126;
|
||||
static final int FIX_INACTIVE_GROUPS = 127;
|
||||
// static final int FIX_INACTIVE_GROUPS = 127;
|
||||
static final int DUPLICATE_E164_FIX = 128;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 127;
|
||||
public static final int CURRENT_VERSION = 128;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
|
@ -714,7 +715,11 @@ public class ApplicationMigrations {
|
|||
// if (lastSeenVersion < Version.FIX_INACTIVE_GROUPS) {
|
||||
// jobs.put(Version.FIX_INACTIVE_GROUPS, new InactiveGroupCheckMigrationJob());
|
||||
// }
|
||||
|
||||
|
||||
if (lastSeenVersion < Version.DUPLICATE_E164_FIX) {
|
||||
jobs.put(Version.DUPLICATE_E164_FIX, new DuplicateE164MigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,207 @@
|
|||
package org.thoughtcrime.securesms.migrations
|
||||
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToMap
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.Companion.ACI_COLUMN
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.Companion.E164
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.Companion.ID
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.Companion.PNI_COLUMN
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.Companion.TABLE_NAME
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Through testing, we've discovered that there are some duplicate E164's in the database. They're not identical strings, since the UNIQUE constraint
|
||||
* would prevent that, but when mapped to a uint64, they become identical.
|
||||
*
|
||||
* The running theory is that there is likely a 0-prefix on some E164's that would cause two numbers to be identical when converted to a uint64.
|
||||
* So we try to find the dupes, clean them up, and in the worst case, merge the two recipients together.
|
||||
*
|
||||
* This is very similar to [BadE164MigrationJob] and re-uses a lot of code (but copies it, rather than DRY's it, to keep the migrations isolated).
|
||||
*
|
||||
* Normally we'd do something like this in a DB migration, but we wanted to have access to recipient merging and number formatting, which could
|
||||
* be unnecessarily difficult in a DB migration.
|
||||
*/
|
||||
internal class DuplicateE164MigrationJob(
|
||||
parameters: Parameters = Parameters.Builder().build()
|
||||
) : MigrationJob(parameters) {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(DuplicateE164MigrationJob::class.java)
|
||||
const val KEY = "DuplicateE164MigrationJob"
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun isUiBlocking(): Boolean = false
|
||||
|
||||
override fun performMigration() {
|
||||
val stopwatch = Stopwatch("dupe-e164")
|
||||
|
||||
val e164sByRecipientId = SignalDatabase.recipients.getAllE164sByRecipientId()
|
||||
val entriesByUint: MutableMap<Long, MutableList<E164Entry>> = mutableMapOf()
|
||||
val invalidE164s: MutableList<E164Entry> = mutableListOf()
|
||||
stopwatch.split("fetch")
|
||||
|
||||
for ((id, e164) in e164sByRecipientId) {
|
||||
val entry = E164Entry(
|
||||
id = id,
|
||||
e164 = e164
|
||||
)
|
||||
|
||||
val e164Uint = e164.convertToLong()
|
||||
if (e164Uint == null) {
|
||||
Log.w(TAG, "[$id] Found an e164 that was inconvertible to a uint64!")
|
||||
invalidE164s += entry
|
||||
continue
|
||||
}
|
||||
|
||||
val existing = entriesByUint.computeIfAbsent(e164Uint) { mutableListOf() }
|
||||
existing += entry
|
||||
}
|
||||
stopwatch.split("convert")
|
||||
|
||||
if (invalidE164s.isNotEmpty()) {
|
||||
Log.w(TAG, "There were ${invalidE164s.size} invalid E164's found that could not be converted to a uint64 at all. Attempting to remove them.")
|
||||
val remainder = attemptToGetRidOfE164(invalidE164s)
|
||||
if (remainder.isNotEmpty()) {
|
||||
Log.w(TAG, "There are still ${remainder.size}/${invalidE164s.size} invalid entries. We'll have to live with them.")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "No invalid E164's found. All could be represented as a uint64.")
|
||||
}
|
||||
stopwatch.split("invalid")
|
||||
|
||||
val dupes = entriesByUint.filter { it.value.size > 1 }
|
||||
if (dupes.isEmpty()) {
|
||||
Log.i(TAG, "No duplicate entries. No action needed!")
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "Found ${dupes.size} unique E164 uint64s that have multiple string mappings. Attempting to repair.")
|
||||
|
||||
for ((_, entries) in dupes) {
|
||||
val resolved = attemptToResolveConflict(entries)
|
||||
|
||||
if (resolved.size <= 1) {
|
||||
Log.w(TAG, "Successfully resolved conflicts for this batch.")
|
||||
continue
|
||||
}
|
||||
|
||||
Log.w(TAG, "Was not able to resolve all conflicts. We must merge the contacts together.")
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
val first = resolved.first()
|
||||
for (entry in resolved.drop(1)) {
|
||||
Log.w(TAG, "Merging ${first.id} with ${entry.id}")
|
||||
SignalDatabase.recipients.mergeForMigration(first.id, entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.split("resolve")
|
||||
stopwatch.stop(TAG)
|
||||
}
|
||||
|
||||
override fun shouldRetry(e: Exception): Boolean = false
|
||||
|
||||
private fun attemptToResolveConflict(entries: List<E164Entry>): List<E164Entry> {
|
||||
val invalidPrefixes = entries.filter { it.e164.startsWith("0") || it.e164.startsWith("+0") || it.e164.startsWith("++") }
|
||||
if (invalidPrefixes.isEmpty()) {
|
||||
Log.w(TAG, "No entries with invalid prefixes, and therefore no evidence as to which duplicate entries would be worth removing.")
|
||||
return entries
|
||||
}
|
||||
|
||||
Log.w(TAG, "Found that ${invalidPrefixes.size}/${entries.size} entries had an invalid prefix. Attempting to strip the e164.")
|
||||
|
||||
return attemptToGetRidOfE164(entries)
|
||||
}
|
||||
|
||||
private fun attemptToGetRidOfE164(entries: List<E164Entry>): List<E164Entry> {
|
||||
val out: MutableList<E164Entry> = entries.toMutableList()
|
||||
|
||||
for (invalidPrefix in entries) {
|
||||
if (SignalDatabase.recipients.removeE164IfAnotherIdentifierIsPresent(invalidPrefix.id)) {
|
||||
Log.w(TAG, "[${invalidPrefix.id}] Successfully removed a conflicting e164 on a recipient that has other identifiers.")
|
||||
out.remove(invalidPrefix)
|
||||
continue
|
||||
}
|
||||
Log.w(TAG, "[${invalidPrefix.id}] Unable to remove a conflicting e164 on a recipient because it has no other identifiers. Attempting to remove the recipient entirely.")
|
||||
|
||||
if (SignalDatabase.recipients.deleteRecipientIfItHasNoMessages(invalidPrefix.id)) {
|
||||
Log.w(TAG, "[${invalidPrefix.id}] Successfully deleted a recipient with a conflicting e164 because it had no messages.")
|
||||
out.remove(invalidPrefix)
|
||||
continue
|
||||
}
|
||||
Log.w(TAG, "[${invalidPrefix.id}] Unable to deleted a recipient with a conflicting e164 because it had messages.")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun RecipientTable.removeE164IfAnotherIdentifierIsPresent(recipientId: RecipientId): Boolean {
|
||||
return readableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(E164 to null)
|
||||
.where("$ID = ? AND ($ACI_COLUMN NOT NULL OR $PNI_COLUMN NOT NULL)", recipientId)
|
||||
.run() > 0
|
||||
}
|
||||
|
||||
private fun RecipientTable.deleteRecipientIfItHasNoMessages(recipientId: RecipientId): Boolean {
|
||||
return readableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
.where(
|
||||
"""
|
||||
$ID = ? AND
|
||||
$ID NOT IN (
|
||||
SELECT ${MessageTable.TO_RECIPIENT_ID} FROM ${MessageTable.TABLE_NAME}
|
||||
UNION
|
||||
SELECT ${MessageTable.FROM_RECIPIENT_ID} FROM ${MessageTable.TABLE_NAME}
|
||||
)
|
||||
""",
|
||||
recipientId
|
||||
)
|
||||
.run() > 0
|
||||
}
|
||||
|
||||
private fun RecipientTable.getAllE164sByRecipientId(): Map<RecipientId, String> {
|
||||
return readableDatabase
|
||||
.select(ID, E164)
|
||||
.from(TABLE_NAME)
|
||||
.where("$E164 NOT NULL")
|
||||
.run()
|
||||
.readToMap {
|
||||
RecipientId.from(it.requireLong(ID)) to it.requireNonNullString(E164)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.convertToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
}
|
||||
|
||||
private data class E164Entry(
|
||||
val id: RecipientId,
|
||||
val e164: String
|
||||
)
|
||||
|
||||
class Factory : Job.Factory<DuplicateE164MigrationJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): DuplicateE164MigrationJob {
|
||||
return DuplicateE164MigrationJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -129,6 +129,11 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier {
|
|||
) {
|
||||
NotificationChannels.getInstance().ensureCustomChannelConsistency()
|
||||
|
||||
if (!Recipient.isSelfSet) {
|
||||
Log.w(TAG, "Attempting to update notifications without local self, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
val currentLockStatus: Boolean = KeyCachingService.isLocked()
|
||||
val currentPrivacyPreference: NotificationPrivacyPreference = SignalStore.settings.messageNotificationsPrivacy
|
||||
val currentScreenLockState: Boolean = ScreenLockController.lockScreenAtStart
|
||||
|
|
|
@ -5,6 +5,7 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
@ -61,7 +62,7 @@ class WhoCanFindMeByPhoneNumberFragment : DSLSettingsFragment(
|
|||
radioPref(
|
||||
title = DSLSettingsText.from(R.string.PhoneNumberPrivacy_nobody),
|
||||
isChecked = state == WhoCanFindMeByPhoneNumberState.NOBODY,
|
||||
onClick = { viewModel.onNobodyCanFindMeByPhoneNumberSelected() }
|
||||
onClick = this@WhoCanFindMeByPhoneNumberFragment::onNobodyCanFindMeByNumberClicked
|
||||
)
|
||||
|
||||
textPref(
|
||||
|
@ -76,4 +77,13 @@ class WhoCanFindMeByPhoneNumberFragment : DSLSettingsFragment(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNobodyCanFindMeByNumberClicked() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_title)
|
||||
.setMessage(getString(R.string.PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_message))
|
||||
.setNegativeButton(getString(R.string.PhoneNumberPrivacySettingsFragment__cancel), null)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.onNobodyCanFindMeByPhoneNumberSelected() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,9 +27,7 @@ class EditReactionsViewModel : ViewModel() {
|
|||
fun onEmojiSelected(emoji: String) {
|
||||
store.update { state ->
|
||||
if (state.selection != NO_SELECTION && state.selection in state.reactions.indices) {
|
||||
if (emoji != EmojiUtil.getCanonicalRepresentation(emoji)) {
|
||||
emojiValues.setPreferredVariation(emoji)
|
||||
}
|
||||
emojiValues.setPreferredVariation(emoji)
|
||||
val preferredEmoji: String = emojiValues.getPreferredVariation(emoji)
|
||||
val newReactions: List<String> = state.reactions.toMutableList().apply { set(state.selection, preferredEmoji) }
|
||||
state.copy(reactions = newReactions)
|
||||
|
@ -40,6 +38,10 @@ class EditReactionsViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
fun resetToDefaults() {
|
||||
EmojiValues.DEFAULT_REACTIONS_LIST.forEach { emoji ->
|
||||
emojiValues.removePreferredVariation(EmojiUtil.getCanonicalRepresentation(emoji))
|
||||
}
|
||||
|
||||
store.update { it.copy(reactions = EmojiValues.DEFAULT_REACTIONS_LIST) }
|
||||
}
|
||||
|
||||
|
|
|
@ -13,14 +13,13 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||
/**
|
||||
* Visual formatter for backup keys.
|
||||
*
|
||||
* @param length max length of key
|
||||
* @param chunkSize character count per group
|
||||
*/
|
||||
class BackupKeyVisualTransformation(private val length: Int, private val chunkSize: Int) : VisualTransformation {
|
||||
class BackupKeyVisualTransformation(private val chunkSize: Int) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
var output = ""
|
||||
for (i in text.take(length).indices) {
|
||||
output += text[i]
|
||||
for ((i, c) in text.withIndex()) {
|
||||
output += c
|
||||
if (i % chunkSize == chunkSize - 1) {
|
||||
output += " "
|
||||
}
|
||||
|
@ -38,7 +37,7 @@ class BackupKeyVisualTransformation(private val length: Int, private val chunkSi
|
|||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
return offset - (offset / chunkSize)
|
||||
return offset - (offset / (chunkSize + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
@ -18,6 +21,7 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
|
@ -36,6 +40,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
@ -47,17 +52,24 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
|
||||
|
@ -72,29 +84,50 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
|||
class EnterBackupKeyFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages"
|
||||
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
|
||||
}
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val viewModel by viewModels<EnterBackupKeyViewModel>()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
sharedViewModel
|
||||
.state
|
||||
.map { it.registerAccountError }
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
sharedViewModel.registerAccountErrorShown()
|
||||
viewModel.handleRegistrationFailure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val sharedState by sharedViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
EnterBackupKeyScreen(
|
||||
backupKey = viewModel.backupKey,
|
||||
state = state,
|
||||
sharedState = sharedState,
|
||||
onBackupKeyChanged = viewModel::updateBackupKey,
|
||||
onNextClicked = {
|
||||
viewModel.registering()
|
||||
sharedViewModel.registerWithBackupKey(
|
||||
context = requireContext(),
|
||||
backupKey = state.backupKey,
|
||||
backupKey = viewModel.backupKey,
|
||||
e164 = null,
|
||||
pin = null
|
||||
)
|
||||
},
|
||||
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
|
||||
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
|
||||
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
|
||||
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
|
||||
)
|
||||
|
@ -104,9 +137,12 @@ class EnterBackupKeyFragment : ComposeFragment() {
|
|||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EnterBackupKeyScreen(
|
||||
backupKey: String,
|
||||
state: EnterBackupKeyState,
|
||||
sharedState: RegistrationState,
|
||||
onBackupKeyChanged: (String) -> Unit = {},
|
||||
onRegistrationErrorDismiss: () -> Unit = {},
|
||||
onBackupKeyHelp: () -> Unit = {},
|
||||
onNextClicked: () -> Unit = {},
|
||||
onLearnMore: () -> Unit = {},
|
||||
onSkip: () -> Unit = {}
|
||||
|
@ -137,26 +173,38 @@ private fun EnterBackupKeyScreen(
|
|||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.backupKeyValid && !sharedState.inProgress,
|
||||
onClick = onNextClicked
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RegistrationActivity_next)
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = state.isRegistering,
|
||||
label = "next-progress"
|
||||
) { isRegistering ->
|
||||
if (isRegistering) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
} else {
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.backupKeyValid && state.aepValidationError == null,
|
||||
onClick = onNextClicked
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RegistrationActivity_next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val visualTransform = remember(state.length, state.chunkLength) { BackupKeyVisualTransformation(length = state.length, chunkSize = state.chunkLength) }
|
||||
val visualTransform = remember(state.chunkLength) { BackupKeyVisualTransformation(chunkSize = state.chunkLength) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
TextField(
|
||||
value = state.backupKey,
|
||||
value = backupKey,
|
||||
onValueChange = onBackupKeyChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
|
||||
},
|
||||
onValueChange = onBackupKeyChanged,
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
|
||||
lineHeight = 36.sp
|
||||
|
@ -168,8 +216,15 @@ private fun EnterBackupKeyScreen(
|
|||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { if (state.backupKeyValid) onNextClicked() }
|
||||
onNext = {
|
||||
if (state.backupKeyValid) {
|
||||
keyboardController?.hide()
|
||||
onNextClicked()
|
||||
}
|
||||
}
|
||||
),
|
||||
supportingText = { state.aepValidationError?.ValidationErrorMessage() },
|
||||
isError = state.aepValidationError != null,
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
|
@ -201,6 +256,40 @@ private fun EnterBackupKeyScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showRegistrationError) {
|
||||
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
|
||||
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
|
||||
confirm = stringResource(R.string.EnterBackupKey_try_again),
|
||||
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
|
||||
onConfirm = {},
|
||||
onDeny = onBackupKeyHelp,
|
||||
onDismiss = onRegistrationErrorDismiss
|
||||
)
|
||||
} else {
|
||||
val message = when (state.registerAccountResult) {
|
||||
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
|
||||
}
|
||||
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
onDismiss = onRegistrationErrorDismiss,
|
||||
dismiss = stringResource(android.R.string.ok)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnterBackupKeyViewModel.AEPValidationError.ValidationErrorMessage() {
|
||||
when (this) {
|
||||
is EnterBackupKeyViewModel.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
|
||||
EnterBackupKeyViewModel.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
|
||||
EnterBackupKeyViewModel.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -209,7 +298,20 @@ private fun EnterBackupKeyScreen(
|
|||
private fun EnterBackupKeyScreenPreview() {
|
||||
Previews.Preview {
|
||||
EnterBackupKeyScreen(
|
||||
state = EnterBackupKeyState(backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", length = 64, chunkLength = 4),
|
||||
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
|
||||
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4),
|
||||
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun EnterBackupKeyScreenErrorPreview() {
|
||||
Previews.Preview {
|
||||
EnterBackupKeyScreen(
|
||||
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
|
||||
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4, aepValidationError = EnterBackupKeyViewModel.AEPValidationError.Invalid),
|
||||
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,50 +5,125 @@
|
|||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
|
||||
class EnterBackupKeyViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]")
|
||||
private val TAG = Log.tag(EnterBackupKeyViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
private val store = MutableStateFlow(
|
||||
EnterBackupKeyState(
|
||||
backupKey = "",
|
||||
length = 64,
|
||||
requiredLength = 64,
|
||||
chunkLength = 4
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<EnterBackupKeyState> = _state
|
||||
var backupKey by mutableStateOf("")
|
||||
private set
|
||||
|
||||
val state: StateFlow<EnterBackupKeyState> = store
|
||||
|
||||
fun updateBackupKey(key: String) {
|
||||
_state.update {
|
||||
val newKey = key.removeIllegalCharacters().take(length).lowercase()
|
||||
copy(backupKey = newKey, backupKeyValid = validate(length, newKey))
|
||||
val newKey = AccountEntropyPool.removeIllegalCharacters(key).lowercase()
|
||||
val changed = newKey != backupKey
|
||||
backupKey = newKey
|
||||
store.update {
|
||||
val isValid = validateContents(backupKey)
|
||||
val isShort = backupKey.length < it.requiredLength
|
||||
val isExact = backupKey.length == it.requiredLength
|
||||
|
||||
var updatedError: AEPValidationError? = checkErrorStillApplies(it.aepValidationError, isShort || isExact, isValid, changed)
|
||||
if (updatedError == null) {
|
||||
updatedError = checkForNewError(isShort, isExact, isValid, it.requiredLength)
|
||||
}
|
||||
|
||||
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(length: Int, backupKey: String): Boolean {
|
||||
return backupKey.length == length
|
||||
private fun validateContents(backupKey: String): Boolean {
|
||||
return AccountEntropyPool.isFullyValid(backupKey)
|
||||
}
|
||||
|
||||
private fun String.removeIllegalCharacters(): String {
|
||||
return this.replace(INVALID_CHARACTERS, "")
|
||||
private fun checkErrorStillApplies(error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
|
||||
return when (error) {
|
||||
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
|
||||
AEPValidationError.Invalid -> if (isValid) null else error
|
||||
AEPValidationError.Incorrect -> if (isChanged) null else error
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> MutableState<T>.update(update: T.() -> T) {
|
||||
this.value = this.value.update()
|
||||
private fun checkForNewError(isShort: Boolean, isExact: Boolean, isValid: Boolean, requiredLength: Int): AEPValidationError? {
|
||||
return if (!isShort && !isExact) {
|
||||
AEPValidationError.TooLong(backupKey.length, requiredLength)
|
||||
} else if (!isValid && isExact) {
|
||||
AEPValidationError.Invalid
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun registering() {
|
||||
store.update { it.copy(isRegistering = true) }
|
||||
}
|
||||
|
||||
fun handleRegistrationFailure(registerAccountResult: RegisterAccountResult) {
|
||||
store.update {
|
||||
if (it.isRegistering) {
|
||||
Log.w(TAG, "Unable to register [${registerAccountResult::class.simpleName}]", registerAccountResult.getCause())
|
||||
val incorrectKeyError = registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword
|
||||
|
||||
if (incorrectKeyError && SignalStore.account.restoredAccountEntropyPool) {
|
||||
SignalStore.account.resetAccountEntropyPool()
|
||||
}
|
||||
|
||||
it.copy(
|
||||
isRegistering = false,
|
||||
showRegistrationError = true,
|
||||
registerAccountResult = registerAccountResult,
|
||||
aepValidationError = if (incorrectKeyError) AEPValidationError.Incorrect else it.aepValidationError
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRegistrationError() {
|
||||
store.update {
|
||||
it.copy(
|
||||
showRegistrationError = false,
|
||||
registerAccountResult = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class EnterBackupKeyState(
|
||||
val backupKey: String = "",
|
||||
val backupKeyValid: Boolean = false,
|
||||
val length: Int,
|
||||
val chunkLength: Int
|
||||
val requiredLength: Int,
|
||||
val chunkLength: Int,
|
||||
val isRegistering: Boolean = false,
|
||||
val showRegistrationError: Boolean = false,
|
||||
val registerAccountResult: RegisterAccountResult? = null,
|
||||
val aepValidationError: AEPValidationError? = null
|
||||
)
|
||||
|
||||
sealed interface AEPValidationError {
|
||||
data class TooLong(val count: Int, val max: Int) : AEPValidationError
|
||||
data object Invalid : AEPValidationError
|
||||
data object Incorrect : AEPValidationError
|
||||
}
|
||||
}
|
||||
|
|
|
@ -104,6 +104,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
|
|||
}
|
||||
|
||||
private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) {
|
||||
sharedViewModel.maybePrefillE164(requireContext())
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
|
||||
|
||||
when (userSelection) {
|
||||
|
|
|
@ -653,7 +653,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
|
||||
@Override
|
||||
public void onSave() {
|
||||
SaveAttachmentTask.showWarningDialogIfNecessary(requireContext(), () -> {
|
||||
SaveAttachmentTask.showWarningDialogIfNecessary(requireContext(), 1, () -> {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
performSaveToDisk();
|
||||
return;
|
||||
|
|
|
@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.conversation.v2.computed.FormattedDate
|
|||
import java.text.DateFormatSymbols
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -182,14 +183,30 @@ object DateUtils : android.text.format.DateUtils() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Formats the timestamp as a date, without the year, followed by the time
|
||||
* eg. Jan 15 at 9:00pm
|
||||
* Given a timestamp, formats as "at time".
|
||||
* Pluralization allows for Romance languages to be translated correctly
|
||||
* eg. at 7:23pm, at 13:20
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getOnlyTimeAtString(context: Context, timestamp: Long): String {
|
||||
val time = timestamp.toLocalTime().formatHours(context)
|
||||
val hour = getHour(context, timestamp)
|
||||
|
||||
return context.resources.getQuantityString(R.plurals.DateUtils_time_at, hour, time)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the timestamp as a date, without the year, followed by the time.
|
||||
* Pluralization allows for Romance languages to be translated correctly
|
||||
* eg. on Jan 15 at 9:00pm
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getDateTimeString(context: Context, locale: Locale, timestamp: Long): String {
|
||||
val date = timestamp.toDateString("MMM d", locale)
|
||||
val time = timestamp.toLocalTime().formatHours(context)
|
||||
return context.getString(R.string.DateUtils_date_at, date, time)
|
||||
val hour = getHour(context, timestamp)
|
||||
|
||||
return context.resources.getQuantityString(R.plurals.DateUtils_date_time_at, hour, date, time)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -363,6 +380,16 @@ object DateUtils : android.text.format.DateUtils() {
|
|||
return isToday(time + TimeUnit.DAYS.toMillis(1))
|
||||
}
|
||||
|
||||
private fun getHour(context: Context, timestamp: Long): Int {
|
||||
val cal = Calendar.getInstance(Locale.getDefault())
|
||||
cal.timeInMillis = timestamp
|
||||
return if (context.is24HourFormat()) {
|
||||
cal[Calendar.HOUR_OF_DAY]
|
||||
} else {
|
||||
cal[Calendar.HOUR]
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.is24HourFormat(): Boolean {
|
||||
is24HourDateCache?.let {
|
||||
if (it.lastUpdated.isWithin(10.seconds)) {
|
||||
|
|
|
@ -435,7 +435,7 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTa
|
|||
}
|
||||
}
|
||||
|
||||
public static void showWarningDialogIfNecessary(Context context, Runnable onSave) {
|
||||
public static void showWarningDialogIfNecessary(Context context, int count, Runnable onSave) {
|
||||
if (SignalStore.uiHints().hasDismissedSaveStorageWarning()) {
|
||||
onSave.run();
|
||||
} else {
|
||||
|
@ -443,7 +443,7 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTa
|
|||
.setView(R.layout.dialog_save_attachment)
|
||||
.setTitle(R.string.ConversationFragment__save_to_phone)
|
||||
.setCancelable(true)
|
||||
.setMessage(R.string.ConversationFragment__this_media_will_be_saved)
|
||||
.setMessage(context.getResources().getQuantityString(R.plurals.ConversationFragment__this_media_will_be_saved, count, count))
|
||||
.setPositiveButton(R.string.save, ((dialog, i) -> {
|
||||
CheckBox checkbox = ((AlertDialog) dialog).findViewById(R.id.checkbox);
|
||||
if (checkbox.isChecked()) {
|
||||
|
|
|
@ -54,7 +54,7 @@ object SaveAttachmentUtil {
|
|||
|
||||
private val TAG = Log.tag(SaveAttachmentUtil::class.java)
|
||||
|
||||
fun showWarningDialogIfNecessary(context: Context, onSave: () -> Unit) {
|
||||
fun showWarningDialogIfNecessary(context: Context, count: Int, onSave: () -> Unit) {
|
||||
if (SignalStore.uiHints.hasDismissedSaveStorageWarning()) {
|
||||
onSave()
|
||||
} else {
|
||||
|
@ -62,7 +62,7 @@ object SaveAttachmentUtil {
|
|||
.setView(R.layout.dialog_save_attachment)
|
||||
.setTitle(R.string.ConversationFragment__save_to_phone)
|
||||
.setCancelable(true)
|
||||
.setMessage(R.string.ConversationFragment__this_media_will_be_saved)
|
||||
.setMessage(context.resources.getQuantityString(R.plurals.ConversationFragment__this_media_will_be_saved, count, count))
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
val checkbox = (dialog as AlertDialog).findViewById<CheckBox>(R.id.checkbox)!!
|
||||
if (checkbox.isChecked) {
|
||||
|
|
|
@ -30,6 +30,7 @@ message BackupInfo {
|
|||
// For example, Chats may all be together at the beginning,
|
||||
// or may each immediately precede its first ChatItem.
|
||||
message Frame {
|
||||
// If unset, importers should skip this frame without throwing an error.
|
||||
oneof item {
|
||||
AccountData account = 1;
|
||||
Recipient recipient = 2;
|
||||
|
@ -44,13 +45,13 @@ message Frame {
|
|||
|
||||
message AccountData {
|
||||
enum PhoneNumberSharingMode {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Nobody"
|
||||
EVERYBODY = 1;
|
||||
NOBODY = 2;
|
||||
}
|
||||
message UsernameLink {
|
||||
enum Color {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Blue"
|
||||
BLUE = 1;
|
||||
WHITE = 2;
|
||||
GREY = 3;
|
||||
|
@ -97,6 +98,7 @@ message AccountData {
|
|||
message IAPSubscriberData {
|
||||
bytes subscriberId = 1;
|
||||
|
||||
// If unset, importers should ignore the subscriber data without throwing an error.
|
||||
oneof iapSubscriptionId {
|
||||
// Identifies an Android Play Store IAP subscription.
|
||||
string purchaseToken = 2;
|
||||
|
@ -119,6 +121,7 @@ message AccountData {
|
|||
|
||||
message Recipient {
|
||||
uint64 id = 1; // generated id for reference only within this file
|
||||
// If unset, importers should skip this frame without throwing an error.
|
||||
oneof destination {
|
||||
Contact contact = 2;
|
||||
Group group = 3;
|
||||
|
@ -131,9 +134,9 @@ message Recipient {
|
|||
|
||||
message Contact {
|
||||
enum IdentityState {
|
||||
DEFAULT = 0;
|
||||
DEFAULT = 0; // A valid value -- indicates unset by the user
|
||||
VERIFIED = 1;
|
||||
UNVERIFIED = 2;
|
||||
UNVERIFIED = 2; // Was once verified and is now unverified
|
||||
}
|
||||
|
||||
message Registered {}
|
||||
|
@ -142,7 +145,7 @@ message Contact {
|
|||
}
|
||||
|
||||
enum Visibility {
|
||||
VISIBLE = 0;
|
||||
VISIBLE = 0; // A valid value -- the contact is not hidden
|
||||
HIDDEN = 1;
|
||||
HIDDEN_MESSAGE_REQUEST = 2;
|
||||
}
|
||||
|
@ -159,6 +162,7 @@ message Contact {
|
|||
bool blocked = 5;
|
||||
Visibility visibility = 6;
|
||||
|
||||
// If unset, consider the user to be registered
|
||||
oneof registration {
|
||||
Registered registered = 7;
|
||||
NotRegistered notRegistered = 8;
|
||||
|
@ -177,7 +181,7 @@ message Contact {
|
|||
|
||||
message Group {
|
||||
enum StorySendMode {
|
||||
DEFAULT = 0;
|
||||
DEFAULT = 0; // A valid value -- indicates unset by the user
|
||||
DISABLED = 1;
|
||||
ENABLED = 2;
|
||||
}
|
||||
|
@ -187,6 +191,7 @@ message Group {
|
|||
bool hideStory = 3;
|
||||
StorySendMode storySendMode = 4;
|
||||
GroupSnapshot snapshot = 5;
|
||||
bool blocked = 6;
|
||||
|
||||
// These are simply plaintext copies of the groups proto from Groups.proto.
|
||||
// They should be kept completely in-sync with Groups.proto.
|
||||
|
@ -210,6 +215,7 @@ message Group {
|
|||
}
|
||||
|
||||
message GroupAttributeBlob {
|
||||
// If unset, consider the field it represents to not be present
|
||||
oneof content {
|
||||
string title = 1;
|
||||
bytes avatar = 2;
|
||||
|
@ -220,7 +226,7 @@ message Group {
|
|||
|
||||
message Member {
|
||||
enum Role {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Intepret as "Default"
|
||||
DEFAULT = 1;
|
||||
ADMINISTRATOR = 2;
|
||||
}
|
||||
|
@ -252,7 +258,7 @@ message Group {
|
|||
|
||||
message AccessControl {
|
||||
enum AccessRequired {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Intepret as "Unsatisfiable"
|
||||
ANY = 1;
|
||||
MEMBER = 2;
|
||||
ADMINISTRATOR = 3;
|
||||
|
@ -292,7 +298,7 @@ message Chat {
|
|||
*/
|
||||
message CallLink {
|
||||
enum Restrictions {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Admin Approval"
|
||||
NONE = 1;
|
||||
ADMIN_APPROVAL = 2;
|
||||
}
|
||||
|
@ -306,7 +312,7 @@ message CallLink {
|
|||
|
||||
message AdHocCall {
|
||||
enum State {
|
||||
UNKNOWN_STATE = 0;
|
||||
UNKNOWN_STATE = 0; // Interpret as "Generic"
|
||||
GENERIC = 1;
|
||||
}
|
||||
|
||||
|
@ -322,6 +328,7 @@ message DistributionListItem {
|
|||
// by an all-0 UUID (00000000-0000-0000-0000-000000000000).
|
||||
bytes distributionId = 1; // distribution list ids are uuids
|
||||
|
||||
// If unset, importers should skip the item entirely without showing an error.
|
||||
oneof item {
|
||||
uint64 deletionTimestamp = 2;
|
||||
DistributionList distributionList = 3;
|
||||
|
@ -330,7 +337,7 @@ message DistributionListItem {
|
|||
|
||||
message DistributionList {
|
||||
enum PrivacyMode {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Only with"
|
||||
ONLY_WITH = 1;
|
||||
ALL_EXCEPT = 2;
|
||||
ALL = 3;
|
||||
|
@ -365,12 +372,14 @@ message ChatItem {
|
|||
repeated ChatItem revisions = 6; // ordered from oldest to newest
|
||||
bool sms = 7;
|
||||
|
||||
// If unset, importers should skip this item without throwing an error.
|
||||
oneof directionalDetails {
|
||||
IncomingMessageDetails incoming = 8;
|
||||
OutgoingMessageDetails outgoing = 9;
|
||||
DirectionlessMessageDetails directionless = 10;
|
||||
}
|
||||
|
||||
// If unset, importers should skip this item without throwing an error.
|
||||
oneof item {
|
||||
StandardMessage standardMessage = 11;
|
||||
ContactMessage contactMessage = 12;
|
||||
|
@ -419,6 +428,7 @@ message SendStatus {
|
|||
uint64 recipientId = 1;
|
||||
uint64 timestamp = 2; // the time the status was last updated -- if from a receipt, it should be the sentTime of the receipt
|
||||
|
||||
// If unset, importers should consider the status to be "pending"
|
||||
oneof deliveryStatus {
|
||||
Pending pending = 3;
|
||||
Sent sent = 4;
|
||||
|
@ -455,6 +465,7 @@ message DirectStoryReplyMessage {
|
|||
FilePointer longText = 2;
|
||||
}
|
||||
|
||||
// If unset, importers should ignore the message without throwing an error.
|
||||
oneof reply {
|
||||
TextReply textReply = 1;
|
||||
string emoji = 2;
|
||||
|
@ -473,7 +484,7 @@ message PaymentNotification {
|
|||
|
||||
message FailedTransaction { // Failed payments can't be synced from the ledger
|
||||
enum FailureReason {
|
||||
GENERIC = 0;
|
||||
GENERIC = 0; // A valid value -- reason unknown
|
||||
NETWORK = 1;
|
||||
INSUFFICIENT_FUNDS = 2;
|
||||
}
|
||||
|
@ -482,7 +493,7 @@ message PaymentNotification {
|
|||
|
||||
message Transaction {
|
||||
enum Status {
|
||||
INITIAL = 0;
|
||||
INITIAL = 0; // A valid value -- state unconfirmed
|
||||
SUBMITTED = 1;
|
||||
SUCCESSFUL = 2;
|
||||
}
|
||||
|
@ -499,6 +510,7 @@ message PaymentNotification {
|
|||
optional bytes receipt = 7; // mobile coin blobs
|
||||
}
|
||||
|
||||
// If unset, importers should treat the transaction as successful with no metadata.
|
||||
oneof payment {
|
||||
Transaction transaction = 1;
|
||||
FailedTransaction failedTransaction = 2;
|
||||
|
@ -513,7 +525,7 @@ message PaymentNotification {
|
|||
|
||||
message GiftBadge {
|
||||
enum State {
|
||||
UNOPENED = 0;
|
||||
UNOPENED = 0; // A valid state
|
||||
OPENED = 1;
|
||||
REDEEMED = 2;
|
||||
FAILED = 3;
|
||||
|
@ -541,7 +553,7 @@ message ContactAttachment {
|
|||
|
||||
message Phone {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Home"
|
||||
HOME = 1;
|
||||
MOBILE = 2;
|
||||
WORK = 3;
|
||||
|
@ -555,7 +567,7 @@ message ContactAttachment {
|
|||
|
||||
message Email {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Intepret as "Home"
|
||||
HOME = 1;
|
||||
MOBILE = 2;
|
||||
WORK = 3;
|
||||
|
@ -569,7 +581,7 @@ message ContactAttachment {
|
|||
|
||||
message PostalAddress {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Home"
|
||||
HOME = 1;
|
||||
WORK = 2;
|
||||
CUSTOM = 3;
|
||||
|
@ -629,7 +641,7 @@ message MessageAttachment {
|
|||
// but explicitly mutually exclusive. Note the different raw values
|
||||
// (non-zero starting values are not supported in proto3.)
|
||||
enum Flag {
|
||||
NONE = 0;
|
||||
NONE = 0; // A valid value -- no flag applied
|
||||
VOICE_MESSAGE = 1;
|
||||
BORDERLESS = 2;
|
||||
GIF = 3;
|
||||
|
@ -680,6 +692,7 @@ message FilePointer {
|
|||
message InvalidAttachmentLocator {
|
||||
}
|
||||
|
||||
// If unset, importers should consider it to be an InvalidAttachmentLocator without throwing an error.
|
||||
oneof locator {
|
||||
BackupLocator backupLocator = 1;
|
||||
AttachmentLocator attachmentLocator = 2;
|
||||
|
@ -698,7 +711,7 @@ message FilePointer {
|
|||
|
||||
message Quote {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Normal"
|
||||
NORMAL = 1;
|
||||
GIFT_BADGE = 2;
|
||||
VIEW_ONCE = 3;
|
||||
|
@ -719,7 +732,7 @@ message Quote {
|
|||
|
||||
message BodyRange {
|
||||
enum Style {
|
||||
NONE = 0;
|
||||
NONE = 0; // Importers should ignore the body range without throwing an error.
|
||||
BOLD = 1;
|
||||
ITALIC = 2;
|
||||
SPOILER = 3;
|
||||
|
@ -727,9 +740,12 @@ message BodyRange {
|
|||
MONOSPACE = 5;
|
||||
}
|
||||
|
||||
optional uint32 start = 1;
|
||||
optional uint32 length = 2;
|
||||
// 'start' and 'length' are measured in UTF-16 code units.
|
||||
// They may refer to offsets in a longText attachment.
|
||||
uint32 start = 1;
|
||||
uint32 length = 2;
|
||||
|
||||
// If unset, importers should ignore the body range without throwing an error.
|
||||
oneof associatedValue {
|
||||
bytes mentionAci = 3;
|
||||
Style style = 4;
|
||||
|
@ -746,6 +762,7 @@ message Reaction {
|
|||
}
|
||||
|
||||
message ChatUpdateMessage {
|
||||
// If unset, importers should ignore the update message without throwing an error.
|
||||
oneof update {
|
||||
SimpleChatUpdate simpleUpdate = 1;
|
||||
GroupChangeChatUpdate groupChange = 2;
|
||||
|
@ -761,19 +778,19 @@ message ChatUpdateMessage {
|
|||
|
||||
message IndividualCall {
|
||||
enum Type {
|
||||
UNKNOWN_TYPE = 0;
|
||||
UNKNOWN_TYPE = 0; // Interpret as "Audio call"
|
||||
AUDIO_CALL = 1;
|
||||
VIDEO_CALL = 2;
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
UNKNOWN_DIRECTION = 0;
|
||||
UNKNOWN_DIRECTION = 0; // Interpret as "Incoming"
|
||||
INCOMING = 1;
|
||||
OUTGOING = 2;
|
||||
}
|
||||
|
||||
enum State {
|
||||
UNKNOWN_STATE = 0;
|
||||
UNKNOWN_STATE = 0; // Interpret as "Accepted"
|
||||
ACCEPTED = 1;
|
||||
NOT_ACCEPTED = 2;
|
||||
// An incoming call that is no longer ongoing, which we neither accepted
|
||||
|
@ -794,7 +811,7 @@ message IndividualCall {
|
|||
|
||||
message GroupCall {
|
||||
enum State {
|
||||
UNKNOWN_STATE = 0;
|
||||
UNKNOWN_STATE = 0; // Interpret as "Generic"
|
||||
// A group call was started without ringing.
|
||||
GENERIC = 1;
|
||||
// We joined a group call that was started without ringing.
|
||||
|
@ -825,7 +842,7 @@ message GroupCall {
|
|||
|
||||
message SimpleChatUpdate {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Importers should skip the update without throwing an error.
|
||||
JOINED_SIGNAL = 1;
|
||||
IDENTITY_UPDATE = 2;
|
||||
IDENTITY_VERIFIED = 3;
|
||||
|
@ -859,6 +876,7 @@ message ProfileChangeChatUpdate {
|
|||
}
|
||||
|
||||
message LearnedProfileChatUpdate {
|
||||
// If unset, importers should consider the previous name to be an empty string.
|
||||
oneof previousName {
|
||||
uint64 e164 = 1;
|
||||
string username = 2;
|
||||
|
@ -875,6 +893,7 @@ message SessionSwitchoverChatUpdate {
|
|||
|
||||
message GroupChangeChatUpdate {
|
||||
message Update {
|
||||
// If unset, importers should consider it to be a GenericGroupUpdate with unset updaterAci
|
||||
oneof update {
|
||||
GenericGroupUpdate genericGroupUpdate = 1;
|
||||
GroupCreationUpdate groupCreationUpdate = 2;
|
||||
|
@ -944,7 +963,7 @@ message GroupDescriptionUpdate {
|
|||
}
|
||||
|
||||
enum GroupV2AccessLevel {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Unsatisfiable"
|
||||
ANY = 1;
|
||||
MEMBER = 2;
|
||||
ADMINISTRATOR = 3;
|
||||
|
@ -1134,6 +1153,7 @@ message ChatStyle {
|
|||
message CustomChatColor {
|
||||
uint64 id = 1;
|
||||
|
||||
// If unset, use the default chat color
|
||||
oneof color {
|
||||
fixed32 solid = 2; // 0xAARRGGBB
|
||||
Gradient gradient = 3;
|
||||
|
@ -1144,7 +1164,7 @@ message ChatStyle {
|
|||
}
|
||||
|
||||
enum WallpaperPreset {
|
||||
UNKNOWN_WALLPAPER_PRESET = 0;
|
||||
UNKNOWN_WALLPAPER_PRESET = 0; // Interpret as the wallpaper being unset
|
||||
SOLID_BLUSH = 1;
|
||||
SOLID_COPPER = 2;
|
||||
SOLID_DUST = 3;
|
||||
|
@ -1169,7 +1189,7 @@ message ChatStyle {
|
|||
}
|
||||
|
||||
enum BubbleColorPreset {
|
||||
UNKNOWN_BUBBLE_COLOR_PRESET = 0;
|
||||
UNKNOWN_BUBBLE_COLOR_PRESET = 0; // Interpret as the user's default chat bubble color
|
||||
SOLID_ULTRAMARINE = 1;
|
||||
SOLID_CRIMSON = 2;
|
||||
SOLID_VERMILION = 3;
|
||||
|
@ -1194,6 +1214,7 @@ message ChatStyle {
|
|||
GRADIENT_TANGERINE = 22;
|
||||
}
|
||||
|
||||
// If unset, importers should consider there to be no wallpaper.
|
||||
oneof wallpaper {
|
||||
WallpaperPreset wallpaperPreset = 1;
|
||||
// This `FilePointer` is expected not to contain a `fileName`, `width`,
|
||||
|
@ -1201,6 +1222,7 @@ message ChatStyle {
|
|||
FilePointer wallpaperPhoto = 2;
|
||||
}
|
||||
|
||||
// If unset, importers should consider it to be AutomaticBubbleColor
|
||||
oneof bubbleColor {
|
||||
// Bubble setting is automatically determined based on the wallpaper setting,
|
||||
// or `SOLID_ULTRAMARINE` for `noWallpaper`
|
||||
|
@ -1216,7 +1238,7 @@ message ChatStyle {
|
|||
|
||||
message NotificationProfile {
|
||||
enum DayOfWeek {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Monday"
|
||||
MONDAY = 1;
|
||||
TUESDAY = 2;
|
||||
WEDNESDAY = 3;
|
||||
|
@ -1242,7 +1264,7 @@ message NotificationProfile {
|
|||
message ChatFolder {
|
||||
// Represents the default "All chats" folder record vs all other custom folders
|
||||
enum FolderType {
|
||||
UNKNOWN = 0;
|
||||
UNKNOWN = 0; // Interpret as "Custom"
|
||||
ALL = 1;
|
||||
CUSTOM = 2;
|
||||
}
|
||||
|
@ -1257,4 +1279,4 @@ message ChatFolder {
|
|||
FolderType folderType = 6;
|
||||
repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self
|
||||
repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self
|
||||
}
|
||||
}
|
|
@ -585,7 +585,10 @@
|
|||
<!-- Dialog title asking to save media to your phone\'s storage -->
|
||||
<string name="ConversationFragment__save_to_phone">Stoor op foon?</string>
|
||||
<!-- Dialog message explaining that media will be saved to your phone and can potentially be accessed by other phones. -->
|
||||
<string name="ConversationFragment__this_media_will_be_saved">Hierdie media sal in jou foon se stoorruimte gestoor word. Ander toepassings kan moontlik toegang daartoe kry, na gelang van jou foon se toestemmings.</string>
|
||||
<plurals name="ConversationFragment__this_media_will_be_saved">
|
||||
<item quantity="one">This media will be saved to your phone\'s storage. Other apps may be able to access it depending on your phone\'s permissions.</item>
|
||||
<item quantity="other">This media will be saved to your phone\'s storage. Other apps may be able to access it depending on your phone\'s permissions.</item>
|
||||
</plurals>
|
||||
<!-- Checkbox shown in dialog to not show the dialog again in future cases -->
|
||||
<string name="ConversationFragment_dont_show_again">Moenie weer wys nie</string>
|
||||
<string name="ConversationFragment_pending">Hangend…</string>
|
||||
|
@ -923,8 +926,16 @@
|
|||
<string name="DateUtils_tomorrow">Môre</string>
|
||||
<!-- Used in the context: Tonight at 9:00pm for example. Specifically this is after 7pm -->
|
||||
<string name="DateUtils_tonight">Vanaand</string>
|
||||
<!-- Used when showing the time a device was linked. %1$s is replaced with the date while %2$s is replaced with the time. e.g. Jan 15 at 9:00pm -->
|
||||
<string name="DateUtils_date_at">%1$s om %2$s</string>
|
||||
<!-- Used when showing the time a device was linked. %1$s is replaced with the date while %2$s is replaced with the time. e.g. on Jan 15 at 9:00pm -->
|
||||
<plurals name="DateUtils_date_time_at">
|
||||
<item quantity="one">on %1$s at %2$s</item>
|
||||
<item quantity="other">on %1$s at %2$s</item>
|
||||
</plurals>
|
||||
<!-- Used when showing the time a device was linked. e.g. at 9:00pm -->
|
||||
<plurals name="DateUtils_time_at">
|
||||
<item quantity="one">at %1$s</item>
|
||||
<item quantity="other">at %1$s</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Scheduled Messages -->
|
||||
<!-- Title for dialog that shows all the users scheduled messages for a chat -->
|
||||
|
@ -1033,7 +1044,7 @@
|
|||
<!-- Dialog title shown when a device is unlinked -->
|
||||
<string name="LinkDeviceFragment__device_unlinked">Toestel ontkoppel</string>
|
||||
<!-- Dialog body shown when a device is unlinked where %1$s is the date and time the device was originally linked (eg Jan 15 at 9:00pm) -->
|
||||
<string name="LinkDeviceFragment__the_device_that_was">Die toestel wat op %1$s gekoppel is, is nie meer gekoppel nie.</string>
|
||||
<string name="LinkDeviceFragment__the_device_that_was">The device that was linked %1$s is no longer linked.</string>
|
||||
<!-- Button to dismiss dialog -->
|
||||
<string name="LinkDeviceFragment__ok">Goed</string>
|
||||
|
||||
|
@ -1084,7 +1095,7 @@
|
|||
<!-- Title of notification telling users that a device was linked to their account -->
|
||||
<string name="NewLinkedDeviceNotification__you_linked_new_device">Jy het \'n nuwe toestel gekoppel</string>
|
||||
<!-- Message body of notification telling users that a device was linked to their account. %1$s is the time it was linked -->
|
||||
<string name="NewLinkedDeviceNotification__a_new_device_was_linked">\'n Nuwe toestel is om %1$s aan jou rekening gekoppel. Tik om te bekyk.</string>
|
||||
<string name="NewLinkedDeviceNotification__a_new_device_was_linked">A new device was linked to your account %1$s. Tap to view.</string>
|
||||
|
||||
<!-- DeviceListActivity -->
|
||||
<string name="DeviceListActivity_unlink_s">Ontkoppel \"%1$s\"?</string>
|
||||
|
@ -1680,7 +1691,7 @@
|
|||
<string name="Megaphones_add_a_profile_photo">Profielfoto</string>
|
||||
|
||||
<!-- Message body of megaphone telling users that a device was linked to their account. %1$s is the time it was linked -->
|
||||
<string name="NewLinkedDeviceMegaphone__a_new_device_was_linked">\'n Nuwe toestel is om %1$s aan jou rekening gekoppel.</string>
|
||||
<string name="NewLinkedDeviceMegaphone__a_new_device_was_linked">A new device was linked to your account %1$s.</string>
|
||||
<!-- Button shown on megaphone that will redirect to the linked devices screen -->
|
||||
<string name="NewLinkedDeviceMegaphone__view_device">Bekyk toestel</string>
|
||||
<!-- Button shown on megaphone to acknowledge and dismiss the megaphone -->
|
||||
|
@ -1959,12 +1970,16 @@
|
|||
<string name="MessageRecord_s_is_in_the_call_s">%1$s is in die oproep · %2$s</string>
|
||||
<!-- Chat log text for an ongoing group call only the local user has joined with a placeholder for formatted time -->
|
||||
<string name="MessageRecord_you_are_in_the_call_s1">Jy is in die oproep · %1$s</string>
|
||||
<!-- Chat log text for an ongoing group call with you and another participant where %1$s is the short display name of the other user and %2$s is the formatted time -->
|
||||
<string name="MessageRecord_s_and_you_are_in_the_call_s1">%1$s and you are in the call · %2$s</string>
|
||||
<!-- Chat log text for an ongoing group call with two participants with two placeholders for the short display name of the users that joined and a placeholder for formatted time -->
|
||||
<string name="MessageRecord_s_and_s_are_in_the_call_s1">%1$s en %2$s is in die oproep · %3$s</string>
|
||||
<!-- Chat log text for an ongoing group call that has one participant -->
|
||||
<string name="MessageRecord_s_is_in_the_call">%1$s is in die oproep</string>
|
||||
<!-- Chat log text for an ongoing group call only the local user has joined -->
|
||||
<string name="MessageRecord_you_are_in_the_call">Jy is in die oproep</string>
|
||||
<!-- Chat log text for an ongoing group call with you and another participant where %1$s is the short display name of the other user -->
|
||||
<string name="MessageRecord_s_and_you_are_in_the_call">%1$s and you are in the call</string>
|
||||
<!-- Chat log text for an ongoing group call with two participants with two placeholders, each for the short display name of the users that joined -->
|
||||
<string name="MessageRecord_s_and_s_are_in_the_call">%1$s en %2$s is in die oproep</string>
|
||||
<!-- Chat log text for a group call that ended within the last 5 minutes -->
|
||||
|
@ -8048,6 +8063,20 @@
|
|||
<string name="EnterBackupKey_learn_more">Vind meer uit</string>
|
||||
<!-- Backup key not known bottom sheet button to skip backup key entry and resume normal reregistration -->
|
||||
<string name="EnterBackupKey_skip_and_dont_restore">Slaan oor en moenie herwin nie</string>
|
||||
<!-- Dialog title shown when entered AEP doesn\'t work to register with provided e164 -->
|
||||
<string name="EnterBackupKey_incorrect_backup_key_title">Incorrect backup key</string>
|
||||
<!-- Dialog message shown when entered AEP doesn\'t work to register with provided e164 -->
|
||||
<string name="EnterBackupKey_incorrect_backup_key_message">Make sure you\'re registering with the same phone number and 64-character backup key you saved when enabling Signal backups. Backups can not be recovered without this key.</string>
|
||||
<!-- Dialog positive button text to try entering their backup key again -->
|
||||
<string name="EnterBackupKey_try_again">Try again</string>
|
||||
<!-- Dialog negative button text to get help with backup key -->
|
||||
<string name="EnterBackupKey_backup_key_help">Backup key help</string>
|
||||
<!-- Text field error text when the backup key provided has been confirmed invalid -->
|
||||
<string name="EnterBackupKey_incorrect_backup_key_error">Incorrect backup key</string>
|
||||
<!-- Text field error text when the backup key is too long -->
|
||||
<string name="EnterBackupKey_too_long_error">Too long (%1$d/%2$d)</string>
|
||||
<!-- Text field error when the backup key is invalid -->
|
||||
<string name="EnterBackupKey_invalid_backup_key_error">Invalid backup key</string>
|
||||
|
||||
<!-- Title for restore via qr screen -->
|
||||
<string name="RestoreViaQr_title">Skandeer hierdie kode met jou ou foon</string>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue