Refactor backup exporting.
This commit is contained in:
parent
813a92380b
commit
13708e33e4
22 changed files with 1838 additions and 1735 deletions
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
typealias ArchiveRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
|
||||
typealias ArchiveGroup = org.thoughtcrime.securesms.backup.v2.proto.Group
|
|
@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.attachments.Cdn
|
|||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
|
||||
|
@ -285,7 +285,7 @@ object BackupRepository {
|
|||
// We're using a snapshot, so the transaction is more for perf than correctness
|
||||
dbSnapshot.rawWritableDatabase.withinTransaction {
|
||||
progressEmitter?.onAccount()
|
||||
AccountDataProcessor.export(dbSnapshot, signalStoreSnapshot) {
|
||||
AccountDataBackupProcessor.export(dbSnapshot, signalStoreSnapshot) {
|
||||
writer.write(it)
|
||||
eventTimer.emit("account")
|
||||
}
|
||||
|
@ -412,7 +412,7 @@ object BackupRepository {
|
|||
for (frame in frameReader) {
|
||||
when {
|
||||
frame.account != null -> {
|
||||
AccountDataProcessor.import(frame.account, selfId, importState)
|
||||
AccountDataBackupProcessor.import(frame.account, selfId, importState)
|
||||
eventTimer.emit("account")
|
||||
}
|
||||
|
||||
|
|
|
@ -5,30 +5,25 @@
|
|||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.CallLink
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import java.io.Closeable
|
||||
import java.time.Instant
|
||||
|
||||
fun CallLinkTable.getCallLinksForBackup(): BackupCallLinkIterator {
|
||||
fun CallLinkTable.getCallLinksForBackup(): CallLinkArchiveExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select()
|
||||
.from(CallLinkTable.TABLE_NAME)
|
||||
.run()
|
||||
|
||||
return BackupCallLinkIterator(cursor)
|
||||
return CallLinkArchiveExportIterator(cursor)
|
||||
}
|
||||
|
||||
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
|
||||
|
@ -53,50 +48,6 @@ fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class BackupCallLinkIterator(private val cursor: Cursor) : Iterator<BackupRecipient>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callLink = CallLinkTable.CallLinkDeserializer.deserialize(cursor)
|
||||
return BackupRecipient(
|
||||
id = callLink.recipientId.toLong(),
|
||||
callLink = CallLink(
|
||||
rootKey = callLink.credentials?.linkKeyBytes?.toByteString() ?: ByteString.EMPTY,
|
||||
adminKey = callLink.credentials?.adminPassBytes?.toByteString(),
|
||||
name = callLink.state.name,
|
||||
expirationMs = try {
|
||||
callLink.state.expiration.toEpochMilli()
|
||||
} catch (e: ArithmeticException) {
|
||||
Long.MAX_VALUE
|
||||
},
|
||||
restrictions = callLink.state.restrictions.toRemote()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallLinkState.Restrictions.toRemote(): CallLink.Restrictions {
|
||||
return when (this) {
|
||||
CallLinkState.Restrictions.ADMIN_APPROVAL -> CallLink.Restrictions.ADMIN_APPROVAL
|
||||
CallLinkState.Restrictions.NONE -> CallLink.Restrictions.NONE
|
||||
CallLinkState.Restrictions.UNKNOWN -> CallLink.Restrictions.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallLink.Restrictions.toLocal(): CallLinkState.Restrictions {
|
||||
return when (this) {
|
||||
CallLink.Restrictions.ADMIN_APPROVAL -> CallLinkState.Restrictions.ADMIN_APPROVAL
|
||||
|
|
|
@ -5,18 +5,14 @@
|
|||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import java.io.Closeable
|
||||
|
||||
fun CallTable.getAdhocCallsForBackup(): CallLogIterator {
|
||||
return CallLogIterator(
|
||||
fun CallTable.getAdhocCallsForBackup(): AdHocCallArchiveExportIterator {
|
||||
return AdHocCallArchiveExportIterator(
|
||||
readableDatabase
|
||||
.select()
|
||||
.from(CallTable.TABLE_NAME)
|
||||
|
@ -31,7 +27,7 @@ fun CallTable.restoreCallLogFromBackup(call: AdHocCall, importState: ImportState
|
|||
AdHocCall.State.UNKNOWN_STATE -> CallTable.Event.GENERIC_GROUP_CALL
|
||||
}
|
||||
|
||||
val result = writableDatabase
|
||||
writableDatabase
|
||||
.insertInto(CallTable.TABLE_NAME)
|
||||
.values(
|
||||
CallTable.CALL_ID to call.callId,
|
||||
|
@ -42,34 +38,4 @@ fun CallTable.restoreCallLogFromBackup(call: AdHocCall, importState: ImportState
|
|||
CallTable.TIMESTAMP to call.callTimestamp
|
||||
)
|
||||
.run()
|
||||
return Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class CallLogIterator(private val cursor: Cursor) : Iterator<AdHocCall?>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): AdHocCall? {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callId = cursor.requireLong(CallTable.CALL_ID)
|
||||
|
||||
return AdHocCall(
|
||||
callId = callId,
|
||||
recipientId = cursor.requireLong(CallTable.PEER),
|
||||
state = AdHocCall.State.GENERIC,
|
||||
callTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -5,81 +5,31 @@
|
|||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireObject
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
|
||||
import org.thoughtcrime.securesms.backup.v2.exporters.DistributionListArchiveExportIterator
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDistributionList
|
||||
|
||||
private val TAG = Log.tag(DistributionListTables::class.java)
|
||||
|
||||
data class DistributionRecipient(val id: RecipientId, val record: DistributionListRecord)
|
||||
|
||||
fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
|
||||
val records = readableDatabase
|
||||
fun DistributionListTables.getAllForBackup(): DistributionListArchiveExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select()
|
||||
.from(DistributionListTables.ListTable.TABLE_NAME)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID))
|
||||
val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
val recipientId: RecipientId = RecipientId.from(cursor.requireLong(DistributionListTables.ListTable.RECIPIENT_ID))
|
||||
DistributionRecipient(
|
||||
id = recipientId,
|
||||
record = DistributionListRecord(
|
||||
id = id,
|
||||
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
|
||||
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
|
||||
allowsReplies = cursor.requireBoolean(DistributionListTables.ListTable.ALLOWS_REPLIES),
|
||||
rawMembers = getRawMembers(id, privacyMode),
|
||||
members = getMembersForBackup(id),
|
||||
deletedAtTimestamp = cursor.requireLong(DistributionListTables.ListTable.DELETION_TIMESTAMP),
|
||||
isUnknown = cursor.requireBoolean(DistributionListTables.ListTable.IS_UNKNOWN),
|
||||
privacyMode = privacyMode
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return records
|
||||
.map { recipient ->
|
||||
BackupRecipient(
|
||||
id = recipient.id.toLong(),
|
||||
distributionList = if (recipient.record.deletedAtTimestamp != 0L) {
|
||||
DistributionListItem(
|
||||
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
deletionTimestamp = recipient.record.deletedAtTimestamp
|
||||
)
|
||||
} else {
|
||||
DistributionListItem(
|
||||
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
distributionList = DistributionList(
|
||||
name = recipient.record.name,
|
||||
allowReplies = recipient.record.allowsReplies,
|
||||
privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(),
|
||||
memberRecipientIds = recipient.record.members.map { it.toLong() }
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return DistributionListArchiveExportIterator(cursor, this)
|
||||
}
|
||||
|
||||
fun DistributionListTables.getMembersForBackup(id: DistributionListId): List<RecipientId> {
|
||||
|
@ -142,14 +92,6 @@ fun DistributionListTables.clearAllDataForBackupRestore() {
|
|||
writableDatabase.deleteAll(DistributionListTables.MembershipTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributionList.PrivacyMode {
|
||||
return when (this) {
|
||||
DistributionListPrivacyMode.ONLY_WITH -> BackupDistributionList.PrivacyMode.ONLY_WITH
|
||||
DistributionListPrivacyMode.ALL -> BackupDistributionList.PrivacyMode.ALL
|
||||
DistributionListPrivacyMode.ALL_EXCEPT -> BackupDistributionList.PrivacyMode.ALL_EXCEPT
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupDistributionList.PrivacyMode.toLocalPrivacyMode(): DistributionListPrivacyMode {
|
||||
return when (this) {
|
||||
BackupDistributionList.PrivacyMode.UNKNOWN -> DistributionListPrivacyMode.ALL
|
||||
|
|
|
@ -6,17 +6,17 @@
|
|||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExportIterator
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = Log.tag(MessageTable::class.java)
|
||||
private const val BASE_TYPE = "base_type"
|
||||
private const val COLUMN_BASE_TYPE = "base_type"
|
||||
|
||||
fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Boolean): ChatItemExportIterator {
|
||||
fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, mediaBackupEnabled: Boolean): ChatItemArchiveExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
MessageTable.ID,
|
||||
|
@ -50,7 +50,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Bool
|
|||
MessageTable.READ,
|
||||
MessageTable.NETWORK_FAILURES,
|
||||
MessageTable.MISMATCHED_IDENTITIES,
|
||||
"${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}",
|
||||
"${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS $COLUMN_BASE_TYPE",
|
||||
MessageTable.MESSAGE_EXTRAS
|
||||
)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
|
@ -66,7 +66,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Bool
|
|||
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
|
||||
.run()
|
||||
|
||||
return ChatItemExportIterator(cursor, 100, mediaBackupEnabled)
|
||||
return ChatItemArchiveExportIterator(db, cursor, 100, mediaBackupEnabled)
|
||||
}
|
||||
|
||||
fun MessageTable.createChatItemInserter(importState: ImportState): ChatItemImportInserter {
|
||||
|
|
|
@ -6,20 +6,12 @@
|
|||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
|
@ -35,14 +27,14 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
|||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState
|
||||
import org.thoughtcrime.securesms.backup.v2.exporters.ContactArchiveExportIterator
|
||||
import org.thoughtcrime.securesms.backup.v2.exporters.GroupArchiveExportIterator
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Group
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Self
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorHash
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
@ -58,18 +50,15 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
|
|||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.io.Closeable
|
||||
|
||||
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
|
||||
typealias BackupGroup = Group
|
||||
private typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
|
||||
|
||||
/**
|
||||
* Fetches all individual contacts for backups and returns the result as an iterator.
|
||||
* It's important to note that the iterator still needs to be closed after it's used.
|
||||
* It's recommended to use `.use` or a try-with-resources pattern.
|
||||
*/
|
||||
fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator {
|
||||
fun RecipientTable.getContactsForBackup(selfId: Long): ContactArchiveExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
RecipientTable.ID,
|
||||
|
@ -104,10 +93,10 @@ fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator {
|
|||
)
|
||||
.run()
|
||||
|
||||
return BackupContactIterator(cursor, selfId)
|
||||
return ContactArchiveExportIterator(cursor, selfId)
|
||||
}
|
||||
|
||||
fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
|
||||
fun RecipientTable.getGroupsForBackup(): GroupArchiveExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.ID}",
|
||||
|
@ -129,7 +118,7 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
|
|||
.where("${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY} IS NOT NULL")
|
||||
.run()
|
||||
|
||||
return BackupGroupIterator(cursor)
|
||||
return GroupArchiveExportIterator(cursor)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -226,7 +215,7 @@ fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
|
|||
val decryptedState = if (group.snapshot == null) {
|
||||
DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION)
|
||||
} else {
|
||||
group.snapshot.toDecryptedGroup(operations)
|
||||
group.snapshot.toLocal(operations)
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
|
@ -244,12 +233,30 @@ fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
|
|||
val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values)
|
||||
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState, groupSendEndorsements = null)
|
||||
if (restoredId != null) {
|
||||
SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toGroupShowAsStoryState())
|
||||
SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toLocal())
|
||||
}
|
||||
|
||||
return RecipientId.from(recipientId)
|
||||
}
|
||||
|
||||
private fun Group.StorySendMode.toLocal(): GroupTable.ShowAsStoryState {
|
||||
return when (this) {
|
||||
Group.StorySendMode.ENABLED -> GroupTable.ShowAsStoryState.ALWAYS
|
||||
Group.StorySendMode.DISABLED -> GroupTable.ShowAsStoryState.NEVER
|
||||
Group.StorySendMode.DEFAULT -> GroupTable.ShowAsStoryState.IF_ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember {
|
||||
return DecryptedPendingMember(
|
||||
serviceIdBytes = member!!.userId,
|
||||
role = member.role.toLocal(),
|
||||
addedByAci = addedByUserId,
|
||||
timestamp = timestamp,
|
||||
serviceIdCipherText = operations.encryptServiceId(ServiceId.Companion.parseOrNull(member.userId))
|
||||
)
|
||||
}
|
||||
|
||||
private fun Contact.Visibility.toLocal(): Recipient.HiddenState {
|
||||
return when (this) {
|
||||
Contact.Visibility.VISIBLE -> Recipient.HiddenState.NOT_HIDDEN
|
||||
|
@ -280,78 +287,10 @@ private fun Group.Member.Role.toLocal(): Member.Role {
|
|||
}
|
||||
}
|
||||
|
||||
private fun AccessControl.AccessRequired.toSnapshot(): Group.AccessControl.AccessRequired {
|
||||
return when (this) {
|
||||
AccessControl.AccessRequired.UNKNOWN -> Group.AccessControl.AccessRequired.UNKNOWN
|
||||
AccessControl.AccessRequired.ANY -> Group.AccessControl.AccessRequired.ANY
|
||||
AccessControl.AccessRequired.MEMBER -> Group.AccessControl.AccessRequired.MEMBER
|
||||
AccessControl.AccessRequired.ADMINISTRATOR -> Group.AccessControl.AccessRequired.ADMINISTRATOR
|
||||
AccessControl.AccessRequired.UNSATISFIABLE -> Group.AccessControl.AccessRequired.UNSATISFIABLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccessControl.toSnapshot(): Group.AccessControl {
|
||||
return Group.AccessControl(members = members.toSnapshot(), attributes = attributes.toSnapshot(), addFromInviteLink = addFromInviteLink.toSnapshot())
|
||||
}
|
||||
|
||||
private fun Member.Role.toSnapshot(): Group.Member.Role {
|
||||
return when (this) {
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
|
||||
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
|
||||
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
|
||||
}
|
||||
}
|
||||
|
||||
private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
|
||||
if (this.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || this.revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Group.GroupSnapshot(
|
||||
title = Group.GroupAttributeBlob(title = this.title),
|
||||
avatarUrl = this.avatar,
|
||||
disappearingMessagesTimer = this.disappearingMessagesTimer?.takeIf { it.duration > 0 }?.let { Group.GroupAttributeBlob(disappearingMessagesDuration = it.duration) },
|
||||
accessControl = this.accessControl?.toSnapshot(),
|
||||
version = this.revision,
|
||||
members = this.members.map { it.toSnapshot() },
|
||||
membersPendingProfileKey = this.pendingMembers.map { it.toSnapshot() },
|
||||
membersPendingAdminApproval = this.requestingMembers.map { it.toSnapshot() },
|
||||
inviteLinkPassword = this.inviteLinkPassword,
|
||||
description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) },
|
||||
announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED,
|
||||
members_banned = this.bannedMembers.map { it.toSnapshot() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.Member.toLocal(): DecryptedMember {
|
||||
return DecryptedMember(aciBytes = userId, role = role.toLocal(), joinedAtRevision = joinedAtVersion)
|
||||
}
|
||||
|
||||
private fun DecryptedMember.toSnapshot(): Group.Member {
|
||||
return Group.Member(userId = aciBytes, role = role.toSnapshot(), joinedAtVersion = joinedAtRevision)
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember {
|
||||
return DecryptedPendingMember(
|
||||
serviceIdBytes = member!!.userId,
|
||||
role = member.role.toLocal(),
|
||||
addedByAci = addedByUserId,
|
||||
timestamp = timestamp,
|
||||
serviceIdCipherText = operations.encryptServiceId(ServiceId.Companion.parseOrNull(member.userId))
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey {
|
||||
return Group.MemberPendingProfileKey(
|
||||
member = Group.Member(
|
||||
userId = this.serviceIdBytes,
|
||||
role = this.role.toSnapshot()
|
||||
),
|
||||
addedByUserId = this.addedByAci,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember {
|
||||
return DecryptedRequestingMember(
|
||||
aciBytes = this.userId,
|
||||
|
@ -359,13 +298,6 @@ private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMembe
|
|||
)
|
||||
}
|
||||
|
||||
private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval {
|
||||
return Group.MemberPendingAdminApproval(
|
||||
userId = this.aciBytes,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.MemberBanned.toLocal(): DecryptedBannedMember {
|
||||
return DecryptedBannedMember(
|
||||
serviceIdBytes = this.userId,
|
||||
|
@ -373,14 +305,7 @@ private fun Group.MemberBanned.toLocal(): DecryptedBannedMember {
|
|||
)
|
||||
}
|
||||
|
||||
private fun DecryptedBannedMember.toSnapshot(): Group.MemberBanned {
|
||||
return Group.MemberBanned(
|
||||
userId = this.serviceIdBytes,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.GroupSnapshot.toDecryptedGroup(operations: GroupsV2Operations.GroupOperations): DecryptedGroup {
|
||||
private fun Group.GroupSnapshot.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedGroup {
|
||||
return DecryptedGroup(
|
||||
title = this.title?.title ?: "",
|
||||
avatar = this.avatarUrl,
|
||||
|
@ -403,139 +328,6 @@ private fun Contact.toLocalExtras(): RecipientExtras {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator<BackupRecipient?>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupRecipient? {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val id = cursor.requireLong(RecipientTable.ID)
|
||||
if (id == selfId) {
|
||||
return BackupRecipient(
|
||||
id = id,
|
||||
self = Self()
|
||||
)
|
||||
}
|
||||
|
||||
val aci = ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN))
|
||||
val pni = PNI.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN))
|
||||
val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong()
|
||||
val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED))
|
||||
val profileKey = cursor.requireString(RecipientTable.PROFILE_KEY)
|
||||
val extras = RecipientTableCursorUtil.getExtras(cursor)
|
||||
|
||||
if (aci == null && pni == null && e164 == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val contactBuilder = Contact.Builder()
|
||||
.aci(aci?.rawUuid?.toByteArray()?.toByteString())
|
||||
.pni(pni?.rawUuid?.toByteArray()?.toByteString())
|
||||
.username(cursor.requireString(RecipientTable.USERNAME))
|
||||
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
|
||||
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
|
||||
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
|
||||
.profileKey(if (profileKey != null) Base64.decode(profileKey).toByteString() else null)
|
||||
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
|
||||
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME))
|
||||
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME))
|
||||
.hideStory(extras?.hideStory() ?: false)
|
||||
|
||||
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
|
||||
contactBuilder.registered = Contact.Registered()
|
||||
} else {
|
||||
contactBuilder.notRegistered = Contact.NotRegistered(unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP))
|
||||
}
|
||||
|
||||
return BackupRecipient(
|
||||
id = id,
|
||||
contact = contactBuilder.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Recipient.HiddenState.toRemote(): Contact.Visibility {
|
||||
return when (this) {
|
||||
Recipient.HiddenState.NOT_HIDDEN -> return Contact.Visibility.VISIBLE
|
||||
Recipient.HiddenState.HIDDEN -> return Contact.Visibility.HIDDEN
|
||||
Recipient.HiddenState.HIDDEN_MESSAGE_REQUEST -> return Contact.Visibility.HIDDEN_MESSAGE_REQUEST
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val extras = RecipientTableCursorUtil.getExtras(cursor)
|
||||
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
|
||||
|
||||
val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!)
|
||||
|
||||
return BackupRecipient(
|
||||
id = cursor.requireLong(RecipientTable.ID),
|
||||
group = BackupGroup(
|
||||
masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(),
|
||||
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
hideStory = extras?.hideStory() ?: false,
|
||||
storySendMode = showAsStoryState.toGroupStorySendMode(),
|
||||
snapshot = decryptedGroup.toSnapshot()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendMode {
|
||||
return when (this) {
|
||||
GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED
|
||||
GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED
|
||||
GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun Group.StorySendMode.toGroupShowAsStoryState(): GroupTable.ShowAsStoryState {
|
||||
return when (this) {
|
||||
Group.StorySendMode.ENABLED -> GroupTable.ShowAsStoryState.ALWAYS
|
||||
Group.StorySendMode.DISABLED -> GroupTable.ShowAsStoryState.NEVER
|
||||
Group.StorySendMode.DEFAULT -> GroupTable.ShowAsStoryState.IF_ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
private val Contact.formattedE164: String?
|
||||
get() {
|
||||
return e164?.let {
|
||||
|
|
|
@ -5,21 +5,15 @@
|
|||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.decodeOrNull
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.exporters.ChatArchiveExportIterator
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter
|
||||
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocal
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment
|
||||
|
@ -27,15 +21,12 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
|||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.wallpaper.UriChatWallpaper
|
||||
import java.io.Closeable
|
||||
|
||||
private val TAG = Log.tag(ThreadTable::class.java)
|
||||
|
||||
fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatExportIterator {
|
||||
fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatArchiveExportIterator {
|
||||
//language=sql
|
||||
val query = """
|
||||
SELECT
|
||||
|
@ -57,7 +48,7 @@ fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatExportIterator {
|
|||
"""
|
||||
val cursor = readableDatabase.query(query)
|
||||
|
||||
return ChatExportIterator(cursor, db)
|
||||
return ChatArchiveExportIterator(cursor, db)
|
||||
}
|
||||
|
||||
fun ThreadTable.clearAllDataForBackupRestore() {
|
||||
|
@ -107,48 +98,3 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
|
|||
|
||||
return threadId
|
||||
}
|
||||
|
||||
class ChatExportIterator(private val cursor: Cursor, private val db: SignalDatabase) : Iterator<Chat>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): Chat {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
|
||||
|
||||
val chatColors: ChatColors? = cursor.requireBlob(RecipientTable.CHAT_COLORS)?.let { serializedChatColors ->
|
||||
val chatColor = ChatColor.ADAPTER.decodeOrNull(serializedChatColors)
|
||||
chatColor?.let { ChatColors.forChatColor(customChatColorsId, it) }
|
||||
}
|
||||
|
||||
val chatWallpaper: Wallpaper? = cursor.requireBlob(RecipientTable.WALLPAPER)?.let { serializedWallpaper ->
|
||||
Wallpaper.ADAPTER.decodeOrNull(serializedWallpaper)
|
||||
}
|
||||
|
||||
return Chat(
|
||||
id = cursor.requireLong(ThreadTable.ID),
|
||||
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
|
||||
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
|
||||
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
|
||||
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL),
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
chatColors = chatColors,
|
||||
chatColorId = customChatColorsId,
|
||||
chatWallpaper = chatWallpaper
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.requireLong
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class AdHocCallArchiveExportIterator(private val cursor: Cursor) : Iterator<AdHocCall>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): AdHocCall {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callId = cursor.requireLong(CallTable.CALL_ID)
|
||||
|
||||
return AdHocCall(
|
||||
callId = callId,
|
||||
recipientId = cursor.requireLong(CallTable.PEER),
|
||||
state = AdHocCall.State.GENERIC,
|
||||
callTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.CallLink
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class CallLinkArchiveExportIterator(private val cursor: Cursor) : Iterator<ArchiveRecipient>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): ArchiveRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callLink = CallLinkTable.CallLinkDeserializer.deserialize(cursor)
|
||||
return ArchiveRecipient(
|
||||
id = callLink.recipientId.toLong(),
|
||||
callLink = CallLink(
|
||||
rootKey = callLink.credentials?.linkKeyBytes?.toByteString() ?: ByteString.EMPTY,
|
||||
adminKey = callLink.credentials?.adminPassBytes?.toByteString(),
|
||||
name = callLink.state.name,
|
||||
expirationMs = try {
|
||||
callLink.state.expiration.toEpochMilli()
|
||||
} catch (e: ArithmeticException) {
|
||||
Long.MAX_VALUE
|
||||
},
|
||||
restrictions = callLink.state.restrictions.toRemote()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallLinkState.Restrictions.toRemote(): CallLink.Restrictions {
|
||||
return when (this) {
|
||||
CallLinkState.Restrictions.ADMIN_APPROVAL -> CallLink.Restrictions.ADMIN_APPROVAL
|
||||
CallLinkState.Restrictions.NONE -> CallLink.Restrictions.NONE
|
||||
CallLinkState.Restrictions.UNKNOWN -> CallLink.Restrictions.UNKNOWN
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.exporters
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.decodeOrNull
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
|
||||
import java.io.Closeable
|
||||
|
||||
class ChatArchiveExportIterator(private val cursor: Cursor, private val db: SignalDatabase) : Iterator<Chat>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): Chat {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
|
||||
|
||||
val chatColors: ChatColors? = cursor.requireBlob(RecipientTable.CHAT_COLORS)?.let { serializedChatColors ->
|
||||
val chatColor = ChatColor.ADAPTER.decodeOrNull(serializedChatColors)
|
||||
chatColor?.let { ChatColors.forChatColor(customChatColorsId, it) }
|
||||
}
|
||||
|
||||
val chatWallpaper: Wallpaper? = cursor.requireBlob(RecipientTable.WALLPAPER)?.let { serializedWallpaper ->
|
||||
Wallpaper.ADAPTER.decodeOrNull(serializedWallpaper)
|
||||
}
|
||||
|
||||
return Chat(
|
||||
id = cursor.requireLong(ThreadTable.ID),
|
||||
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
|
||||
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
|
||||
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
|
||||
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL),
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
chatColors = chatColors,
|
||||
chatColorId = customChatColorsId,
|
||||
chatWallpaper = chatWallpaper
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.exporters
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Self
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [ArchiveRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class ContactArchiveExportIterator(private val cursor: Cursor, private val selfId: Long) : Iterator<ArchiveRecipient>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): ArchiveRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val id = cursor.requireLong(RecipientTable.ID)
|
||||
if (id == selfId) {
|
||||
return ArchiveRecipient(
|
||||
id = id,
|
||||
self = Self()
|
||||
)
|
||||
}
|
||||
|
||||
val aci = ServiceId.ACI.Companion.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN))
|
||||
val pni = ServiceId.PNI.Companion.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN))
|
||||
val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong()
|
||||
|
||||
if (aci == null && pni == null && e164 == null) {
|
||||
throw IllegalStateException("Should not happen! Query guards against this.")
|
||||
}
|
||||
|
||||
val contactBuilder = Contact.Builder()
|
||||
.aci(aci?.rawUuid?.toByteArray()?.toByteString())
|
||||
.pni(pni?.rawUuid?.toByteArray()?.toByteString())
|
||||
.username(cursor.requireString(RecipientTable.USERNAME))
|
||||
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
|
||||
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
|
||||
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
|
||||
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { Base64.decode(it) }?.toByteString())
|
||||
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
|
||||
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME))
|
||||
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME))
|
||||
.hideStory(RecipientTableCursorUtil.getExtras(cursor)?.hideStory() ?: false)
|
||||
|
||||
val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED))
|
||||
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
|
||||
contactBuilder.registered = Contact.Registered()
|
||||
} else {
|
||||
contactBuilder.notRegistered = Contact.NotRegistered(unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP))
|
||||
}
|
||||
|
||||
return ArchiveRecipient(
|
||||
id = id,
|
||||
contact = contactBuilder.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Recipient.HiddenState.toRemote(): Contact.Visibility {
|
||||
return when (this) {
|
||||
Recipient.HiddenState.NOT_HIDDEN -> return Contact.Visibility.VISIBLE
|
||||
Recipient.HiddenState.HIDDEN -> return Contact.Visibility.HIDDEN
|
||||
Recipient.HiddenState.HIDDEN_MESSAGE_REQUEST -> return Contact.Visibility.HIDDEN_MESSAGE_REQUEST
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.exporters
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireObject
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getMembersForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.io.Closeable
|
||||
|
||||
class DistributionListArchiveExportIterator(
|
||||
private val cursor: Cursor,
|
||||
private val distributionListTables: DistributionListTables
|
||||
) : Iterator<ArchiveRecipient>, Closeable {
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): ArchiveRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID))
|
||||
val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
val recipientId: RecipientId = RecipientId.from(cursor.requireLong(DistributionListTables.ListTable.RECIPIENT_ID))
|
||||
|
||||
val record = DistributionListRecord(
|
||||
id = id,
|
||||
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
|
||||
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
|
||||
allowsReplies = cursor.requireBoolean(DistributionListTables.ListTable.ALLOWS_REPLIES),
|
||||
rawMembers = distributionListTables.getRawMembers(id, privacyMode),
|
||||
members = distributionListTables.getMembersForBackup(id),
|
||||
deletedAtTimestamp = cursor.requireLong(DistributionListTables.ListTable.DELETION_TIMESTAMP),
|
||||
isUnknown = cursor.requireBoolean(DistributionListTables.ListTable.IS_UNKNOWN),
|
||||
privacyMode = privacyMode
|
||||
)
|
||||
|
||||
val distributionListItem = if (record.deletedAtTimestamp != 0L) {
|
||||
DistributionListItem(
|
||||
distributionId = record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
deletionTimestamp = record.deletedAtTimestamp
|
||||
)
|
||||
} else {
|
||||
DistributionListItem(
|
||||
distributionId = record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
distributionList = DistributionList(
|
||||
name = record.name,
|
||||
allowReplies = record.allowsReplies,
|
||||
privacyMode = record.privacyMode.toBackupPrivacyMode(),
|
||||
memberRecipientIds = record.members.map { it.toLong() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return ArchiveRecipient(
|
||||
id = recipientId.toLong(),
|
||||
distributionList = distributionListItem
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun DistributionListPrivacyMode.toBackupPrivacyMode(): DistributionList.PrivacyMode {
|
||||
return when (this) {
|
||||
DistributionListPrivacyMode.ONLY_WITH -> DistributionList.PrivacyMode.ONLY_WITH
|
||||
DistributionListPrivacyMode.ALL -> DistributionList.PrivacyMode.ALL
|
||||
DistributionListPrivacyMode.ALL_EXCEPT -> DistributionList.PrivacyMode.ALL_EXCEPT
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.exporters
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.storageservice.protos.groups.AccessControl
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveGroup
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Group
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [ArchiveRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class GroupArchiveExportIterator(private val cursor: Cursor) : Iterator<ArchiveRecipient>, Closeable {
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): ArchiveRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val extras = RecipientTableCursorUtil.getExtras(cursor)
|
||||
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
|
||||
|
||||
val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!)
|
||||
|
||||
return ArchiveRecipient(
|
||||
id = cursor.requireLong(RecipientTable.ID),
|
||||
group = ArchiveGroup(
|
||||
masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(),
|
||||
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
hideStory = extras?.hideStory() ?: false,
|
||||
storySendMode = showAsStoryState.toRemote(),
|
||||
snapshot = decryptedGroup.toRemote()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun GroupTable.ShowAsStoryState.toRemote(): Group.StorySendMode {
|
||||
return when (this) {
|
||||
GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED
|
||||
GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED
|
||||
GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun DecryptedGroup.toRemote(): Group.GroupSnapshot? {
|
||||
if (this.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || this.revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Group.GroupSnapshot(
|
||||
title = Group.GroupAttributeBlob(title = this.title),
|
||||
avatarUrl = this.avatar,
|
||||
disappearingMessagesTimer = this.disappearingMessagesTimer?.takeIf { it.duration > 0 }?.let { Group.GroupAttributeBlob(disappearingMessagesDuration = it.duration) },
|
||||
accessControl = this.accessControl?.toRemote(),
|
||||
version = this.revision,
|
||||
members = this.members.map { it.toRemote() },
|
||||
membersPendingProfileKey = this.pendingMembers.map { it.toRemote() },
|
||||
membersPendingAdminApproval = this.requestingMembers.map { it.toRemote() },
|
||||
inviteLinkPassword = this.inviteLinkPassword,
|
||||
description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) },
|
||||
announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED,
|
||||
members_banned = this.bannedMembers.map { it.toRemote() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun AccessControl.AccessRequired.toRemote(): Group.AccessControl.AccessRequired {
|
||||
return when (this) {
|
||||
AccessControl.AccessRequired.UNKNOWN -> Group.AccessControl.AccessRequired.UNKNOWN
|
||||
AccessControl.AccessRequired.ANY -> Group.AccessControl.AccessRequired.ANY
|
||||
AccessControl.AccessRequired.MEMBER -> Group.AccessControl.AccessRequired.MEMBER
|
||||
AccessControl.AccessRequired.ADMINISTRATOR -> Group.AccessControl.AccessRequired.ADMINISTRATOR
|
||||
AccessControl.AccessRequired.UNSATISFIABLE -> Group.AccessControl.AccessRequired.UNSATISFIABLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccessControl.toRemote(): Group.AccessControl {
|
||||
return Group.AccessControl(members = members.toRemote(), attributes = attributes.toRemote(), addFromInviteLink = addFromInviteLink.toRemote())
|
||||
}
|
||||
|
||||
private fun Member.Role.toRemote(): Group.Member.Role {
|
||||
return when (this) {
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
|
||||
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
|
||||
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
|
||||
}
|
||||
}
|
||||
|
||||
private fun DecryptedMember.toRemote(): Group.Member {
|
||||
return Group.Member(userId = aciBytes, role = role.toRemote(), joinedAtVersion = joinedAtRevision)
|
||||
}
|
||||
|
||||
private fun DecryptedPendingMember.toRemote(): Group.MemberPendingProfileKey {
|
||||
return Group.MemberPendingProfileKey(
|
||||
member = Group.Member(
|
||||
userId = this.serviceIdBytes,
|
||||
role = this.role.toRemote()
|
||||
),
|
||||
addedByUserId = this.addedByAci,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedBannedMember.toRemote(): Group.MemberBanned {
|
||||
return Group.MemberBanned(
|
||||
userId = this.serviceIdBytes,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedRequestingMember.toRemote(): Group.MemberPendingAdminApproval {
|
||||
return Group.MemberPendingAdminApproval(
|
||||
userId = this.aciBytes,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
|
@ -5,6 +5,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.logging.Log
|
||||
|
@ -40,9 +41,12 @@ import org.whispersystems.signalservice.api.util.UuidUtil
|
|||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.util.Currency
|
||||
|
||||
object AccountDataProcessor {
|
||||
/**
|
||||
* Handles importing/exporting [AccountData] frames for an archive.
|
||||
*/
|
||||
object AccountDataBackupProcessor {
|
||||
|
||||
private val TAG = Log.tag(AccountDataProcessor::class)
|
||||
private val TAG = Log.tag(AccountDataBackupProcessor::class)
|
||||
|
||||
fun export(db: SignalDatabase, signalStore: SignalStore, emitter: BackupFrameEmitter) {
|
||||
val context = AppDependencies.application
|
||||
|
@ -68,7 +72,7 @@ object AccountDataProcessor {
|
|||
AccountData.UsernameLink(
|
||||
entropy = signalStore.accountValues.usernameLink?.entropy?.toByteString() ?: EMPTY,
|
||||
serverId = signalStore.accountValues.usernameLink?.serverId?.toByteArray()?.toByteString() ?: EMPTY,
|
||||
color = signalStore.miscValues.usernameQrCodeColorScheme.toBackupUsernameColor()
|
||||
color = signalStore.miscValues.usernameQrCodeColorScheme.toRemoteUsernameColor()
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
@ -80,7 +84,7 @@ object AccountDataProcessor {
|
|||
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
|
||||
linkPreviews = signalStore.settingsValues.isLinkPreviewsEnabled,
|
||||
notDiscoverableByPhoneNumber = signalStore.phoneNumberPrivacyValues.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE,
|
||||
phoneNumberSharingMode = signalStore.phoneNumberPrivacyValues.phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
|
||||
phoneNumberSharingMode = signalStore.phoneNumberPrivacyValues.phoneNumberSharingMode.toRemotePhoneNumberSharingMode(),
|
||||
preferContactAvatars = signalStore.settingsValues.isPreferSystemContactPhotos,
|
||||
universalExpireTimerSeconds = signalStore.settingsValues.universalExpireTimer,
|
||||
preferredReactionEmoji = signalStore.emojiValues.rawReactions,
|
||||
|
@ -114,107 +118,42 @@ object AccountDataProcessor {
|
|||
val settings = accountData.accountSettings
|
||||
|
||||
if (settings != null) {
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts)
|
||||
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
|
||||
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
|
||||
SignalStore.settings.isLinkPreviewsEnabled = settings.linkPreviews
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
|
||||
SignalStore.settings.isPreferSystemContactPhotos = settings.preferContactAvatars
|
||||
SignalStore.settings.universalExpireTimer = settings.universalExpireTimerSeconds
|
||||
SignalStore.emoji.reactions = settings.preferredReactionEmoji
|
||||
SignalStore.inAppPayments.setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
|
||||
SignalStore.settings.setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
|
||||
SignalStore.story.userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
|
||||
SignalStore.story.userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
|
||||
SignalStore.story.isFeatureDisabled = settings.storiesDisabled
|
||||
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
importSettings(context, settings, importState)
|
||||
}
|
||||
|
||||
settings.customChatColors
|
||||
.mapNotNull { chatColor ->
|
||||
val id = ChatColors.Id.forLongValue(chatColor.id)
|
||||
when {
|
||||
chatColor.solid != null -> {
|
||||
ChatColors.forColor(id, chatColor.solid)
|
||||
}
|
||||
chatColor.gradient != null -> {
|
||||
ChatColors.forGradient(
|
||||
id,
|
||||
ChatColors.LinearGradient(
|
||||
degrees = chatColor.gradient.angle.toFloat(),
|
||||
colors = chatColor.gradient.colors.toIntArray(),
|
||||
positions = chatColor.gradient.positions.toFloatArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.forEach { chatColor ->
|
||||
// We need to use the "NotSet" chatId so that this operation is treated as an insert rather than an update
|
||||
val saved = SignalDatabase.chatColors.saveChatColors(chatColor.withId(ChatColors.Id.NotSet))
|
||||
importState.remoteToLocalColorId[chatColor.id.longValue] = saved.id.longValue
|
||||
}
|
||||
if (accountData.donationSubscriberData != null) {
|
||||
if (accountData.donationSubscriberData.subscriberId.size > 0) {
|
||||
val remoteSubscriberId = SubscriberId.fromBytes(accountData.donationSubscriberData.subscriberId.toByteArray())
|
||||
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
if (settings.defaultChatStyle != null) {
|
||||
val chatColors = settings.defaultChatStyle.toLocal(importState)
|
||||
SignalStore.chatColors.chatColors = chatColors
|
||||
|
||||
val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer ->
|
||||
filePointer.toLocalAttachment(importState)?.let {
|
||||
SignalDatabase.attachments.restoreWallpaperAttachment(it)
|
||||
}
|
||||
}
|
||||
|
||||
SignalStore.wallpaper.wallpaper = settings.defaultChatStyle.parseChatWallpaper(wallpaperAttachmentId)
|
||||
} else {
|
||||
SignalStore.chatColors.chatColors = null
|
||||
SignalStore.wallpaper.wallpaper = null
|
||||
}
|
||||
|
||||
if (accountData.donationSubscriberData != null) {
|
||||
if (accountData.donationSubscriberData.subscriberId.size > 0) {
|
||||
val remoteSubscriberId = SubscriberId.fromBytes(accountData.donationSubscriberData.subscriberId.toByteArray())
|
||||
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
remoteSubscriberId,
|
||||
Currency.getInstance(accountData.donationSubscriberData.currencyCode),
|
||||
InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled,
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
}
|
||||
|
||||
if (accountData.donationSubscriberData.manuallyCancelled) {
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
}
|
||||
}
|
||||
|
||||
if (accountData.avatarUrlPath.isNotEmpty()) {
|
||||
AppDependencies.jobManager.add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath))
|
||||
}
|
||||
|
||||
if (accountData.usernameLink != null) {
|
||||
SignalStore.account.usernameLink = UsernameLinkComponents(
|
||||
accountData.usernameLink.entropy.toByteArray(),
|
||||
UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray())
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
remoteSubscriberId,
|
||||
Currency.getInstance(accountData.donationSubscriberData.currencyCode),
|
||||
InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled,
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
)
|
||||
SignalStore.misc.usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
|
||||
} else {
|
||||
SignalStore.account.usernameLink = null
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
}
|
||||
|
||||
if (settings.preferredReactionEmoji.isNotEmpty()) {
|
||||
SignalStore.emoji.reactions = settings.preferredReactionEmoji
|
||||
if (accountData.donationSubscriberData.manuallyCancelled) {
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.hasCompletedUsernameOnboarding) {
|
||||
SignalStore.uiHints.setHasCompletedUsernameOnboarding(true)
|
||||
}
|
||||
if (accountData.avatarUrlPath.isNotEmpty()) {
|
||||
AppDependencies.jobManager.add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath))
|
||||
}
|
||||
|
||||
if (accountData.usernameLink != null) {
|
||||
SignalStore.account.usernameLink = UsernameLinkComponents(
|
||||
accountData.usernameLink.entropy.toByteArray(),
|
||||
UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray())
|
||||
)
|
||||
SignalStore.misc.usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
|
||||
} else {
|
||||
SignalStore.account.usernameLink = null
|
||||
}
|
||||
|
||||
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
|
||||
|
@ -222,7 +161,76 @@ object AccountDataProcessor {
|
|||
Recipient.self().live().refresh()
|
||||
}
|
||||
|
||||
private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toBackupPhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode {
|
||||
private fun importSettings(context: Context, settings: AccountData.AccountSettings, importState: ImportState) {
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts)
|
||||
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
|
||||
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
|
||||
SignalStore.settings.isLinkPreviewsEnabled = settings.linkPreviews
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
|
||||
SignalStore.settings.isPreferSystemContactPhotos = settings.preferContactAvatars
|
||||
SignalStore.settings.universalExpireTimer = settings.universalExpireTimerSeconds
|
||||
SignalStore.emoji.reactions = settings.preferredReactionEmoji
|
||||
SignalStore.inAppPayments.setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
|
||||
SignalStore.settings.setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
|
||||
SignalStore.story.userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
|
||||
SignalStore.story.userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
|
||||
SignalStore.story.isFeatureDisabled = settings.storiesDisabled
|
||||
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
|
||||
settings.customChatColors
|
||||
.mapNotNull { chatColor ->
|
||||
val id = ChatColors.Id.forLongValue(chatColor.id)
|
||||
when {
|
||||
chatColor.solid != null -> {
|
||||
ChatColors.forColor(id, chatColor.solid)
|
||||
}
|
||||
chatColor.gradient != null -> {
|
||||
ChatColors.forGradient(
|
||||
id,
|
||||
ChatColors.LinearGradient(
|
||||
degrees = chatColor.gradient.angle.toFloat(),
|
||||
colors = chatColor.gradient.colors.toIntArray(),
|
||||
positions = chatColor.gradient.positions.toFloatArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.forEach { chatColor ->
|
||||
// We need to use the "NotSet" chatId so that this operation is treated as an insert rather than an update
|
||||
val saved = SignalDatabase.chatColors.saveChatColors(chatColor.withId(ChatColors.Id.NotSet))
|
||||
importState.remoteToLocalColorId[chatColor.id.longValue] = saved.id.longValue
|
||||
}
|
||||
|
||||
if (settings.defaultChatStyle != null) {
|
||||
val chatColors = settings.defaultChatStyle.toLocal(importState)
|
||||
SignalStore.chatColors.chatColors = chatColors
|
||||
|
||||
val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer ->
|
||||
filePointer.toLocalAttachment(importState)?.let {
|
||||
SignalDatabase.attachments.restoreWallpaperAttachment(it)
|
||||
}
|
||||
}
|
||||
|
||||
SignalStore.wallpaper.wallpaper = settings.defaultChatStyle.parseChatWallpaper(wallpaperAttachmentId)
|
||||
} else {
|
||||
SignalStore.chatColors.chatColors = null
|
||||
SignalStore.wallpaper.wallpaper = null
|
||||
}
|
||||
|
||||
if (settings.preferredReactionEmoji.isNotEmpty()) {
|
||||
SignalStore.emoji.reactions = settings.preferredReactionEmoji
|
||||
}
|
||||
|
||||
if (settings.hasCompletedUsernameOnboarding) {
|
||||
SignalStore.uiHints.setHasCompletedUsernameOnboarding(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toRemotePhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode {
|
||||
return when (this) {
|
||||
PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT -> AccountData.PhoneNumberSharingMode.EVERYBODY
|
||||
PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY -> AccountData.PhoneNumberSharingMode.EVERYBODY
|
||||
|
@ -252,7 +260,7 @@ object AccountDataProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private fun UsernameQrCodeColorScheme.toBackupUsernameColor(): AccountData.UsernameLink.Color {
|
||||
private fun UsernameQrCodeColorScheme.toRemoteUsernameColor(): AccountData.UsernameLink.Color {
|
||||
return when (this) {
|
||||
UsernameQrCodeColorScheme.Blue -> AccountData.UsernameLink.Color.BLUE
|
||||
UsernameQrCodeColorScheme.White -> AccountData.UsernameLink.Color.WHITE
|
|
@ -14,6 +14,9 @@ import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
|||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
/**
|
||||
* Handles importing/exporting [AdHocCall] frames for an archive.
|
||||
*/
|
||||
object AdHocCallBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(AdHocCallBackupProcessor::class.java)
|
||||
|
@ -21,9 +24,7 @@ object AdHocCallBackupProcessor {
|
|||
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
|
||||
db.callTable.getAdhocCallsForBackup().use { reader ->
|
||||
for (callLog in reader) {
|
||||
if (callLog != null) {
|
||||
emitter.emit(Frame(adHocCall = callLog))
|
||||
}
|
||||
emitter.emit(Frame(adHocCall = callLog))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,9 @@ import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
|||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Handles importing/exporting [Chat] frames for an archive.
|
||||
*/
|
||||
object ChatBackupProcessor {
|
||||
val TAG = Log.tag(ChatBackupProcessor::class.java)
|
||||
|
||||
|
|
|
@ -11,17 +11,21 @@ import org.thoughtcrime.securesms.backup.v2.ImportState
|
|||
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
/**
|
||||
* Handles importing/exporting [ChatItem] frames for an archive.
|
||||
*/
|
||||
object ChatItemBackupProcessor {
|
||||
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
|
||||
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
db.messageTable.getMessagesForBackup(exportState.backupTime, exportState.mediaBackupEnabled).use { chatItems ->
|
||||
db.messageTable.getMessagesForBackup(db, exportState.backupTime, exportState.mediaBackupEnabled).use { chatItems ->
|
||||
while (chatItems.hasNext()) {
|
||||
val chatItem = chatItems.next()
|
||||
val chatItem: ChatItem? = chatItems.next()
|
||||
if (chatItem != null) {
|
||||
if (exportState.threadIds.contains(chatItem.chatId)) {
|
||||
emitter.emit(Frame(chatItem = chatItem))
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.BackupRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getCallLinksForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup
|
||||
|
@ -24,6 +24,9 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
|||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Handles importing/exporting [ArchiveRecipient] frames for an archive.
|
||||
*/
|
||||
object RecipientBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(RecipientBackupProcessor::class.java)
|
||||
|
@ -35,7 +38,7 @@ object RecipientBackupProcessor {
|
|||
exportState.recipientIds.add(releaseChannelId.toLong())
|
||||
emitter.emit(
|
||||
Frame(
|
||||
recipient = BackupRecipient(
|
||||
recipient = ArchiveRecipient(
|
||||
id = releaseChannelId.toLong(),
|
||||
releaseNotes = ReleaseNotes()
|
||||
)
|
||||
|
@ -46,33 +49,35 @@ object RecipientBackupProcessor {
|
|||
}
|
||||
|
||||
db.recipientTable.getContactsForBackup(selfId).use { reader ->
|
||||
for (backupRecipient in reader) {
|
||||
if (backupRecipient != null) {
|
||||
exportState.recipientIds.add(backupRecipient.id)
|
||||
emitter.emit(Frame(recipient = backupRecipient))
|
||||
}
|
||||
for (recipient in reader) {
|
||||
exportState.recipientIds.add(recipient.id)
|
||||
emitter.emit(Frame(recipient = recipient))
|
||||
}
|
||||
}
|
||||
|
||||
db.recipientTable.getGroupsForBackup().use { reader ->
|
||||
for (backupRecipient in reader) {
|
||||
exportState.recipientIds.add(backupRecipient.id)
|
||||
emitter.emit(Frame(recipient = backupRecipient))
|
||||
for (recipient in reader) {
|
||||
exportState.recipientIds.add(recipient.id)
|
||||
emitter.emit(Frame(recipient = recipient))
|
||||
}
|
||||
}
|
||||
|
||||
db.distributionListTables.getAllForBackup().forEach {
|
||||
exportState.recipientIds.add(it.id)
|
||||
emitter.emit(Frame(recipient = it))
|
||||
db.distributionListTables.getAllForBackup().use { reader ->
|
||||
for (recipient in reader) {
|
||||
exportState.recipientIds.add(recipient.id)
|
||||
emitter.emit(Frame(recipient = recipient))
|
||||
}
|
||||
}
|
||||
|
||||
db.callLinkTable.getCallLinksForBackup().forEach {
|
||||
exportState.recipientIds.add(it.id)
|
||||
emitter.emit(Frame(recipient = it))
|
||||
db.callLinkTable.getCallLinksForBackup().use { reader ->
|
||||
for (recipient in reader) {
|
||||
exportState.recipientIds.add(recipient.id)
|
||||
emitter.emit(Frame(recipient = recipient))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import(recipient: BackupRecipient, importState: ImportState) {
|
||||
fun import(recipient: ArchiveRecipient, importState: ImportState) {
|
||||
val newId = when {
|
||||
recipient.contact != null -> SignalDatabase.recipients.restoreContactFromBackup(recipient.contact)
|
||||
recipient.group != null -> SignalDatabase.recipients.restoreGroupFromBackup(recipient.group)
|
||||
|
|
|
@ -19,6 +19,9 @@ import org.thoughtcrime.securesms.database.model.StickerPackRecord
|
|||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
|
||||
/**
|
||||
* Handles importing/exporting [StickerPack] frames for an archive.
|
||||
*/
|
||||
object StickerBackupProcessor {
|
||||
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
|
||||
StickerPackRecordReader(db.stickerTable.allStickerPacks).use { reader ->
|
||||
|
|
Loading…
Add table
Reference in a new issue