Merge tag 'v7.32.1' into molly-7.32

This commit is contained in:
Oscar Mira 2025-01-31 15:51:30 +01:00
commit dc5f599a87
No known key found for this signature in database
GPG key ID: B371B98C5DC32237
232 changed files with 17009 additions and 9957 deletions

View file

@ -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)

File diff suppressed because one or more lines are too long

View file

@ -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)
)
}
}

View file

@ -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(

View file

@ -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()
}
)
}
}

View file

@ -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

View file

@ -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();
}
}

View file

@ -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"
}

View file

@ -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) {

View file

@ -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()

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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(

View file

@ -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) {

View file

@ -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,

View file

@ -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}")

View file

@ -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))
}
}

View file

@ -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 = ""
) {

View file

@ -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))
}
)
}

View file

@ -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)
)
}
}
}
}

View file

@ -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(

View file

@ -37,7 +37,7 @@ object IBANVisualTransformation : VisualTransformation {
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / 4)
return offset - (offset / 5)
}
}
}

View file

@ -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)
}
}
}
)
}
)
)
}
)
}
}
}
}

View file

@ -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()
}

View file

@ -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()
}
}

View file

@ -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()
}

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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())
}

View file

@ -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 -> {

View file

@ -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());

View file

@ -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())

View file

@ -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)

View file

@ -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()

View file

@ -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))

View file

@ -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()}.

View file

@ -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();
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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")

View file

@ -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))

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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;
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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) }
}

View file

@ -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))
}
}
}

View file

@ -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)
)
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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;

View file

@ -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)) {

View file

@ -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()) {

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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