Add chat folder support to storage service.

This commit is contained in:
Michelle Tang
2025-04-04 11:36:17 -04:00
parent f6ecb572b1
commit eb1cf8d62f
26 changed files with 1131 additions and 84 deletions

View File

@@ -183,6 +183,10 @@ object ImportSkips {
return log(sentTimestamp, "Failed to find a threadId for the provided chatId. ChatId in backup: $chatId")
}
fun chatFolderIdNotFound(): String {
return log(0, "Failed to parse chatFolderId for the provided chat folder.")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}

View File

@@ -6,10 +6,13 @@
package org.thoughtcrime.securesms.backup.v2.processor
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.insertInto
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportSkips
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder
import org.thoughtcrime.securesms.backup.v2.proto.Frame
@@ -19,6 +22,8 @@ import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderMembership
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.ChatFolderTables.MembershipType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.util.UuidUtil
import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder as ChatFolderProto
/**
@@ -31,7 +36,7 @@ object ChatFolderProcessor {
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
val folders = db
.chatFoldersTable
.getChatFolders()
.getCurrentChatFolders()
.sortedBy { it.position }
if (folders.isEmpty()) {
@@ -66,6 +71,12 @@ object ChatFolderProcessor {
}
fun import(chatFolder: ChatFolderProto, importState: ImportState) {
val chatFolderUuid = UuidUtil.parseOrNull(chatFolder.id)
if (chatFolderUuid == null) {
ImportSkips.chatFolderIdNotFound()
return
}
val chatFolderId = SignalDatabase
.writableDatabase
.insertInto(ChatFolderTable.TABLE_NAME)
@@ -76,7 +87,9 @@ object ChatFolderProcessor {
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.includeAllIndividualChats,
ChatFolderTable.SHOW_GROUPS to chatFolder.includeAllGroupChats,
ChatFolderTable.FOLDER_TYPE to chatFolder.folderType.toLocal().value
ChatFolderTable.FOLDER_TYPE to chatFolder.folderType.toLocal().value,
ChatFolderTable.CHAT_FOLDER_ID to chatFolderUuid.toString(),
ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey())
)
.run()
@@ -110,7 +123,8 @@ private fun ChatFolderRecord.toBackupFrame(includedRecipientIds: List<Long>, exc
else -> throw IllegalStateException("Only ALL or CUSTOM should be in the db")
},
includedRecipientIds = includedRecipientIds,
excludedRecipientIds = excludedRecipientIds
excludedRecipientIds = excludedRecipientIds,
id = UuidUtil.toByteArray(this.chatFolderId.uuid).toByteString()
)
return Frame(chatFolder = chatFolder)

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
/**
* UUID wrapper class for chat folders, used in storage service
*/
@Parcelize
data class ChatFolderId(val uuid: UUID) : Parcelable {
companion object {
fun from(id: String): ChatFolderId {
return ChatFolderId(UuidUtil.parseOrThrow(id))
}
fun from(uuid: UUID): ChatFolderId {
return ChatFolderId(uuid)
}
fun generate(): ChatFolderId {
return ChatFolderId(UUID.randomUUID())
}
}
override fun toString(): String {
return uuid.toString()
}
}

View File

@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.whispersystems.signalservice.api.storage.StorageId
/**
* Represents an entry in the [org.thoughtcrime.securesms.database.ChatFolderTables].
@@ -17,7 +19,12 @@ data class ChatFolderRecord(
val showMutedChats: Boolean = true,
val showIndividualChats: Boolean = false,
val showGroupChats: Boolean = false,
val folderType: FolderType = FolderType.CUSTOM
val folderType: FolderType = FolderType.CUSTOM,
val chatFolderId: ChatFolderId = ChatFolderId.generate(),
@IgnoredOnParcel
val storageServiceId: StorageId? = null,
val storageServiceProto: ByteArray? = null,
val deletedTimestampMs: Long = 0
) : Parcelable {
enum class FolderType(val value: Int) {
/** Folder containing all chats */

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.chats.folders
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/**
* Repository for chat folders that handles creation, deletion, listing, etc.,
@@ -9,7 +10,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
object ChatFoldersRepository {
fun getCurrentFolders(): List<ChatFolderRecord> {
return SignalDatabase.chatFolders.getChatFolders()
return SignalDatabase.chatFolders.getCurrentChatFolders()
}
fun getUnreadCountAndMutedStatusForFolders(folders: List<ChatFolderRecord>): HashMap<Long, Pair<Int, Boolean>> {
@@ -25,6 +26,7 @@ object ChatFoldersRepository {
)
SignalDatabase.chatFolders.createFolder(updatedFolder)
StorageSyncHelper.scheduleSyncForDataChange()
}
fun updateFolder(folder: ChatFolderRecord, includedRecipients: Set<Recipient>, excludedRecipients: Set<Recipient>) {
@@ -36,21 +38,29 @@ object ChatFoldersRepository {
)
SignalDatabase.chatFolders.updateFolder(updatedFolder)
scheduleSync(updatedFolder.id)
}
fun deleteFolder(folder: ChatFolderRecord) {
SignalDatabase.chatFolders.deleteChatFolder(folder)
scheduleSync(folder.id)
}
fun updatePositions(folders: List<ChatFolderRecord>) {
SignalDatabase.chatFolders.updatePositions(folders)
folders.forEach { scheduleSync(it.id) }
}
fun getFolder(id: Long): ChatFolderRecord {
return SignalDatabase.chatFolders.getChatFolder(id)
return SignalDatabase.chatFolders.getChatFolder(id)!!
}
fun getFolderCount(): Int {
return SignalDatabase.chatFolders.getFolderCount()
}
private fun scheduleSync(id: Long) {
SignalDatabase.chatFolders.markNeedsSync(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}

View File

@@ -97,7 +97,7 @@ class CreateFoldersFragment : ComposeFragment() {
val isNewFolder = state.originalFolder.folderRecord.id == -1L
LaunchedEffect(Unit) {
if (state.originalFolder == state.currentFolder) {
if (state.originalFolder.folderRecord.id == state.currentFolder.folderRecord.id) {
viewModel.setCurrentFolderId(arguments?.getLong(KEY_FOLDER_ID) ?: -1)
viewModel.addThreadsToFolder(arguments?.getLongArray(KEY_THREAD_IDS))
}

View File

@@ -3,23 +3,43 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import androidx.core.content.contentValuesOf
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.count
import org.signal.core.util.delete
import org.signal.core.util.groupBy
import org.signal.core.util.hasUnknownFields
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.readToMap
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLongOrNull
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.database.ThreadTable.Companion.ID
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord
/**
* Stores chat folders and the chats that belong in each chat folder
@@ -27,6 +47,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), ThreadIdDatabaseReference {
companion object {
private val TAG = Log.tag(ChatFolderTable::class.java)
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(ChatFolderTable.CREATE_TABLE, ChatFolderMembershipTable.CREATE_TABLE)
@@ -43,7 +65,9 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
ChatFolderTable.FOLDER_TYPE to ChatFolderRecord.FolderType.ALL.value,
ChatFolderTable.SHOW_INDIVIDUAL to 1,
ChatFolderTable.SHOW_GROUPS to 1,
ChatFolderTable.SHOW_MUTED to 1
ChatFolderTable.SHOW_MUTED to 1,
ChatFolderTable.CHAT_FOLDER_ID to ChatFolderId.generate().toString(),
ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey())
)
}
}
@@ -61,8 +85,11 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
const val SHOW_MUTED = "show_muted"
const val SHOW_INDIVIDUAL = "show_individual"
const val SHOW_GROUPS = "show_groups"
const val IS_MUTED = "is_muted"
const val FOLDER_TYPE = "folder_type"
const val CHAT_FOLDER_ID = "chat_folder_id"
const val STORAGE_SERVICE_ID = "storage_service_id"
const val STORAGE_SERVICE_PROTO = "storage_service_proto"
const val DELETED_TIMESTAMP_MS = "deleted_timestamp_ms"
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
@@ -73,8 +100,11 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
$SHOW_MUTED INTEGER DEFAULT 0,
$SHOW_INDIVIDUAL INTEGER DEFAULT 0,
$SHOW_GROUPS INTEGER DEFAULT 0,
$IS_MUTED INTEGER DEFAULT 0,
$FOLDER_TYPE INTEGER DEFAULT ${ChatFolderRecord.FolderType.CUSTOM.value}
$FOLDER_TYPE INTEGER DEFAULT ${ChatFolderRecord.FolderType.CUSTOM.value},
$CHAT_FOLDER_ID TEXT DEFAULT NULL,
$STORAGE_SERVICE_ID TEXT DEFAULT NULL,
$STORAGE_SERVICE_PROTO TEXT DEFAULT NULL,
$DELETED_TIMESTAMP_MS INTEGER DEFAULT 0
)
"""
@@ -119,10 +149,28 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
.run()
}
/**
* Returns a single chat folder that corresponds to that query.
* Assumes query will only match to one chat folder.
*/
fun getChatFolder(query: SqlUtil.Query): ChatFolderRecord? {
val id = readableDatabase
.select(ChatFolderTable.ID)
.from(ChatFolderTable.TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToSingleLongOrNull()
return getChatFolder(id)
}
/**
* Returns a single chat folder that corresponds to that id
*/
fun getChatFolder(id: Long): ChatFolderRecord {
fun getChatFolder(id: Long?): ChatFolderRecord? {
if (id == null) {
return null
}
val includedChats: Map<Long, List<Long>> = getIncludedChats(id)
val excludedChats: Map<Long, List<Long>> = getExcludedChats(id)
@@ -142,7 +190,11 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
showGroupChats = cursor.requireBoolean(ChatFolderTable.SHOW_GROUPS),
folderType = ChatFolderRecord.FolderType.deserialize(cursor.requireInt(ChatFolderTable.FOLDER_TYPE)),
includedChats = includedChats[id] ?: emptyList(),
excludedChats = excludedChats[id] ?: emptyList()
excludedChats = excludedChats[id] ?: emptyList(),
chatFolderId = ChatFolderId.from(cursor.requireNonNullString(ChatFolderTable.CHAT_FOLDER_ID)),
storageServiceId = StorageId.forChatFolder(Base64.decodeNullableOrThrow(cursor.requireString(ChatFolderTable.STORAGE_SERVICE_ID))),
storageServiceProto = Base64.decodeOrNull(cursor.requireString(ChatFolderTable.STORAGE_SERVICE_PROTO)),
deletedTimestampMs = cursor.requireLong(ChatFolderTable.DELETED_TIMESTAMP_MS)
)
}
@@ -150,15 +202,16 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
}
/**
* Maps the chat folder ids to its corresponding chat folder
* Returns all non-deleted chat folders
*/
fun getChatFolders(): List<ChatFolderRecord> {
fun getCurrentChatFolders(): List<ChatFolderRecord> {
val includedChats: Map<Long, List<Long>> = getIncludedChats()
val excludedChats: Map<Long, List<Long>> = getExcludedChats()
val folders = readableDatabase
.select()
.from(ChatFolderTable.TABLE_NAME)
.where("${ChatFolderTable.DELETED_TIMESTAMP_MS} = 0")
.orderBy(ChatFolderTable.POSITION)
.run()
.readToList { cursor ->
@@ -173,7 +226,10 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
showGroupChats = cursor.requireBoolean(ChatFolderTable.SHOW_GROUPS),
folderType = ChatFolderRecord.FolderType.deserialize(cursor.requireInt(ChatFolderTable.FOLDER_TYPE)),
includedChats = includedChats[id] ?: emptyList(),
excludedChats = excludedChats[id] ?: emptyList()
excludedChats = excludedChats[id] ?: emptyList(),
chatFolderId = ChatFolderId.from(cursor.requireNonNullString((ChatFolderTable.CHAT_FOLDER_ID))),
storageServiceId = StorageId.forChatFolder(Base64.decodeNullableOrThrow(cursor.requireString(RecipientTable.STORAGE_SERVICE_ID))),
storageServiceProto = Base64.decodeOrNull(cursor.requireString(ChatFolderTable.STORAGE_SERVICE_PROTO))
)
}
@@ -194,54 +250,13 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
}
/**
* Maps a chat folder id to all of its corresponding included chats.
* If an id is not specified, all chat folder ids will be mapped.
*/
private fun getIncludedChats(id: Long? = null): Map<Long, List<Long>> {
val whereQuery = if (id != null) {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value} AND ${ChatFolderMembershipTable.CHAT_FOLDER_ID} = $id"
} else {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}"
}
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where(whereQuery)
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
}
}
/**
* Maps a chat folder id to all of its corresponding excluded chats.
* If an id is not specified, all chat folder ids will be mapped.
*/
private fun getExcludedChats(id: Long? = null): Map<Long, List<Long>> {
val whereQuery = if (id != null) {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value} AND ${ChatFolderMembershipTable.CHAT_FOLDER_ID} = $id"
} else {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}"
}
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where(whereQuery)
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
}
}
/**
* Returns the number of folders a user has, including the default 'All Chats'
* Returns the number of non-deleted folders a user has, including the default 'All Chats'
*/
fun getFolderCount(): Int {
return readableDatabase
.count()
.from(ChatFolderTable.TABLE_NAME)
.where("${ChatFolderTable.DELETED_TIMESTAMP_MS} = 0")
.run()
.readToSingleInt()
}
@@ -257,6 +272,8 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
.run()
.readToSingleInt(0) + 1
val storageId = chatFolder.storageServiceId?.raw ?: StorageSyncHelper.generateKey()
val storageServiceProto = if (chatFolder.storageServiceProto != null) Base64.encodeWithPadding(chatFolder.storageServiceProto) else null
val id = db.insertInto(ChatFolderTable.TABLE_NAME)
.values(
contentValuesOf(
@@ -265,7 +282,11 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
ChatFolderTable.POSITION to position
ChatFolderTable.POSITION to if (chatFolder.position == -1 && chatFolder.deletedTimestampMs == 0L) position else chatFolder.position,
ChatFolderTable.CHAT_FOLDER_ID to chatFolder.chatFolderId.toString(),
ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId),
ChatFolderTable.STORAGE_SERVICE_PROTO to storageServiceProto,
ChatFolderTable.DELETED_TIMESTAMP_MS to chatFolder.deletedTimestampMs
)
)
.run(SQLiteDatabase.CONFLICT_IGNORE)
@@ -298,14 +319,18 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
* Updates the details for an existing folder like name, chat types, etc.
*/
fun updateFolder(chatFolder: ChatFolderRecord) {
val storageServiceProto = if (chatFolder.storageServiceProto != null) Base64.encodeWithPadding(chatFolder.storageServiceProto) else null
writableDatabase.withinTransaction { db ->
db.update(ChatFolderTable.TABLE_NAME)
.values(
ChatFolderTable.NAME to chatFolder.name,
ChatFolderTable.POSITION to chatFolder.position,
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
ChatFolderTable.STORAGE_SERVICE_PROTO to storageServiceProto,
ChatFolderTable.DELETED_TIMESTAMP_MS to chatFolder.deletedTimestampMs
)
.where("${ChatFolderTable.ID} = ?", chatFolder.id)
.run(SQLiteDatabase.CONFLICT_IGNORE)
@@ -336,11 +361,23 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
}
/**
* Deletes a chat folder
* Marks a chat folder as deleted and removes all associated chats
*/
fun deleteChatFolder(chatFolder: ChatFolderRecord) {
writableDatabase.withinTransaction { db ->
db.delete(ChatFolderTable.TABLE_NAME, "${ChatFolderTable.ID} = ?", SqlUtil.buildArgs(chatFolder.id))
db.update(ChatFolderTable.TABLE_NAME)
.values(
ChatFolderTable.DELETED_TIMESTAMP_MS to System.currentTimeMillis(),
ChatFolderTable.POSITION to -1
)
.where("$ID = ?", chatFolder.id)
.run()
db.delete(ChatFolderMembershipTable.TABLE_NAME)
.where("${ChatFolderMembershipTable.CHAT_FOLDER_ID} = ?", chatFolder.id)
.run()
resetPositions()
AppDependencies.databaseObserver.notifyChatFolderObservers()
}
}
@@ -405,6 +442,253 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
}
}
/**
* Saves the new storage id for a chat folder
*/
fun applyStorageIdUpdate(id: ChatFolderId, storageId: StorageId) {
applyStorageIdUpdates(hashMapOf(id to storageId))
}
/**
* Saves the new storage ids for all the chat folders in the map
*/
fun applyStorageIdUpdates(storageIds: Map<ChatFolderId, StorageId>) {
writableDatabase.withinTransaction { db ->
storageIds.forEach { (chatFolderId, storageId) ->
db.update(ChatFolderTable.TABLE_NAME)
.values(ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId.raw))
.where("${ChatFolderTable.CHAT_FOLDER_ID} = ?", chatFolderId.toString())
.run()
}
}
}
/**
* Maps all chat folder ids to storage ids
*/
fun getStorageSyncIdsMap(): Map<ChatFolderId, StorageId> {
return readableDatabase
.select(ChatFolderTable.CHAT_FOLDER_ID, ChatFolderTable.STORAGE_SERVICE_ID)
.from(ChatFolderTable.TABLE_NAME)
.run()
.readToMap { cursor ->
val id = ChatFolderId.from(cursor.requireNonNullString(ChatFolderTable.CHAT_FOLDER_ID))
val encodedKey = cursor.requireNonNullString(ChatFolderTable.STORAGE_SERVICE_ID)
val key = Base64.decodeOrThrow(encodedKey)
id to StorageId.forChatFolder(key)
}
}
/**
* Returns a list of all the storage ids used in chat folders
*/
fun getStorageSyncIds(): List<StorageId> {
return readableDatabase
.select(ChatFolderTable.STORAGE_SERVICE_ID)
.from(ChatFolderTable.TABLE_NAME)
.run()
.readToList { cursor ->
val encodedKey = cursor.requireNonNullString(ChatFolderTable.STORAGE_SERVICE_ID)
val key = Base64.decodeOrThrow(encodedKey)
StorageId.forChatFolder(key)
}
}
/**
* Creates a new storage id for a folder. Assumption is that StorageSyncHelper.scheduleSyncForDataChange() will be called after.
*/
fun markNeedsSync(folderId: Long) {
markNeedsSync(listOf(folderId))
}
/**
* Creates new storage ids for multiple folders. Assumption is that StorageSyncHelper.scheduleSyncForDataChange() will be called after.
*/
fun markNeedsSync(folderIds: List<Long>) {
writableDatabase.withinTransaction {
for (id in folderIds) {
rotateStorageId(id)
}
}
}
/**
* Inserts a remote chat folder into the database
*/
fun insertChatFolderFromStorageSync(record: SignalChatFolderRecord) {
if (record.proto.folderType == RemoteChatFolderRecord.FolderType.ALL) {
Log.i(TAG, "All chats should already exists. Avoiding inserting another and only updating relevant fields")
val storageServiceProto = if (record.proto.hasUnknownFields()) Base64.encodeWithPadding(record.serializedUnknowns!!) else null
writableDatabase.withinTransaction { db ->
db.update(ChatFolderTable.TABLE_NAME)
.values(
ChatFolderTable.POSITION to record.proto.position,
ChatFolderTable.CHAT_FOLDER_ID to ChatFolderId.from(UuidUtil.parseOrThrow(record.proto.identifier)).toString(),
ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(record.id.raw),
ChatFolderTable.STORAGE_SERVICE_PROTO to storageServiceProto
)
.where("${ChatFolderTable.FOLDER_TYPE} = ?", ChatFolderRecord.FolderType.ALL.value)
.run(SQLiteDatabase.CONFLICT_IGNORE)
}
} else {
createFolder(remoteChatFolderRecordToLocal(record))
}
}
/**
* Updates an existing local chat folder with the details of the remote chat folder
*/
fun updateChatFolderFromStorageSync(record: SignalChatFolderRecord) {
updateFolder(remoteChatFolderRecordToLocal(record))
}
/**
* Maps a chat folder id to all of its corresponding included chats.
* If an id is not specified, all chat folder ids will be mapped.
*/
private fun getIncludedChats(id: Long? = null): Map<Long, List<Long>> {
val whereQuery = if (id != null) {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value} AND ${ChatFolderMembershipTable.CHAT_FOLDER_ID} = $id"
} else {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}"
}
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where(whereQuery)
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
}
}
/**
* Maps a chat folder id to all of its corresponding excluded chats.
* If an id is not specified, all chat folder ids will be mapped.
*/
private fun getExcludedChats(id: Long? = null): Map<Long, List<Long>> {
val whereQuery = if (id != null) {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value} AND ${ChatFolderMembershipTable.CHAT_FOLDER_ID} = $id"
} else {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}"
}
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where(whereQuery)
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
}
}
/**
* Ensures that chat folders positions are 0-indexed and consecutive
*/
private fun resetPositions() {
val folders = readableDatabase
.select(ChatFolderTable.ID)
.from(ChatFolderTable.TABLE_NAME)
.where("${ChatFolderTable.DELETED_TIMESTAMP_MS} = 0")
.orderBy("${ChatFolderTable.POSITION} ASC")
.run()
.readToList { cursor -> cursor.requireLong(ChatFolderTable.ID) }
writableDatabase.withinTransaction { db ->
folders.forEachIndexed { index, id ->
db.update(ChatFolderTable.TABLE_NAME)
.values(ChatFolderTable.POSITION to index)
.where("${ChatFolderTable.ID} = ?", id)
.run(SQLiteDatabase.CONFLICT_IGNORE)
}
}
}
/**
* Rotates the storage id for a chat folder
*/
private fun rotateStorageId(id: Long) {
writableDatabase
.update(ChatFolderTable.TABLE_NAME)
.values(ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
.where("${ChatFolderTable.ID} = ?", id)
.run()
}
/**
* Given a [ChatFolderId], return the id for that folder
*/
private fun getIdByRemoteChatFolderId(chatFolderId: ChatFolderId): Long? {
return readableDatabase
.select(ChatFolderTable.ID)
.from(ChatFolderTable.TABLE_NAME)
.where("${ChatFolderTable.CHAT_FOLDER_ID} = ?", chatFolderId.toString())
.run()
.readToSingleLongOrNull()
}
/**
* Parses a remote chat folder [SignalChatFolderRecord] into a local folder [ChatFolderRecord]
*/
private fun remoteChatFolderRecordToLocal(record: SignalChatFolderRecord): ChatFolderRecord {
val chatFolderId = ChatFolderId.from(UuidUtil.parseOrThrow(record.proto.identifier))
val id = getIdByRemoteChatFolderId(chatFolderId)
return ChatFolderRecord(
id = id ?: -1,
name = record.proto.name,
position = record.proto.position,
showUnread = record.proto.showOnlyUnread,
showMutedChats = record.proto.showMutedChats,
showIndividualChats = record.proto.includeAllIndividualChats,
showGroupChats = record.proto.includeAllGroupChats,
folderType = when (record.proto.folderType) {
RemoteChatFolderRecord.FolderType.ALL -> ChatFolderRecord.FolderType.ALL
RemoteChatFolderRecord.FolderType.CUSTOM -> ChatFolderRecord.FolderType.CUSTOM
RemoteChatFolderRecord.FolderType.UNKNOWN -> throw AssertionError("Folder type cannot be unknown")
},
includedChats = record.proto.includedRecipients
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) }
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
excludedChats = record.proto.excludedRecipients
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) }
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
chatFolderId = chatFolderId,
storageServiceId = StorageId.forChatFolder(record.id.raw),
storageServiceProto = record.serializedUnknowns,
deletedTimestampMs = record.proto.deletedAtTimestampMs
)
}
/**
* Parses a remote recipient into a local one. Used when configuring the chats of a remote chat folder into a local one.
*/
private fun getRecipientIdFromRemoteRecipient(remoteRecipient: RemoteChatFolderRecord.Recipient): Recipient? {
return if (remoteRecipient.contact != null) {
val serviceId = ServiceId.parseOrNull(remoteRecipient.contact!!.serviceId)
val e164 = remoteRecipient.contact!!.e164
Recipient.externalPush(SignalServiceAddress(serviceId, e164))
} else if (remoteRecipient.legacyGroupId != null) {
try {
Recipient.externalGroupExact(GroupId.v1(remoteRecipient.legacyGroupId!!.toByteArray()))
} catch (e: BadGroupIdException) {
Log.w(TAG, "Failed to parse groupV1 ID!", e)
null
}
} else if (remoteRecipient.groupMasterKey != null) {
try {
Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(remoteRecipient.groupMasterKey!!.toByteArray())))
} catch (e: InvalidInputException) {
Log.w(TAG, "Failed to parse groupV2 master key!", e)
null
}
} else {
Log.w(TAG, "Could not find recipient")
null
}
}
private fun Collection<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
return map {
contentValuesOf(

View File

@@ -124,6 +124,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V266_UniqueThreadPi
import org.thoughtcrime.securesms.database.helpers.migration.V267_FixGroupInvitationDeclinedUpdate
import org.thoughtcrime.securesms.database.helpers.migration.V268_FixInAppPaymentsErrorStateConsistency
import org.thoughtcrime.securesms.database.helpers.migration.V269_BackupMediaSnapshotChanges
import org.thoughtcrime.securesms.database.helpers.migration.V270_FixChatFolderColumnsForStorageSync
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -253,10 +254,11 @@ object SignalDatabaseMigrations {
266 to V266_UniqueThreadPinOrder,
267 to V267_FixGroupInvitationDeclinedUpdate,
268 to V268_FixInAppPaymentsErrorStateConsistency,
269 to V269_BackupMediaSnapshotChanges
269 to V269_BackupMediaSnapshotChanges,
270 to V270_FixChatFolderColumnsForStorageSync
)
const val DATABASE_VERSION = 269
const val DATABASE_VERSION = 270
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.thoughtcrime.securesms.database.SQLiteDatabase
import java.util.UUID
/**
* Adds four columns chat_folder_id, storage_service_id, storage_service_proto and deleted_timestamp_ms to support chat folders in storage sync.
* Removes unnecessary is_muted column in chat folders table.
*/
@Suppress("ClassName")
object V270_FixChatFolderColumnsForStorageSync : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE chat_folder DROP COLUMN is_muted")
db.execSQL("ALTER TABLE chat_folder ADD COLUMN chat_folder_id TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE chat_folder ADD COLUMN storage_service_id TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE chat_folder ADD COLUMN storage_service_proto TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE chat_folder ADD COLUMN deleted_timestamp_ms INTEGER DEFAULT 0")
// Assign all of the folders with a [ChatFolderId] and reset position
db.rawQuery("SELECT _id FROM chat_folder ORDER BY position ASC")
.readToList { it.requireLong("_id") }
.forEachIndexed { index, id -> resetPositionAndSetChatFolderId(db, id, index) }
}
private fun resetPositionAndSetChatFolderId(db: SQLiteDatabase, id: Long, newPosition: Int) {
db.rawQuery(
"""
UPDATE chat_folder
SET chat_folder_id = '${UUID.randomUUID()}', position = $newPosition
WHERE _id = $id
"""
)
}
}

View File

@@ -95,6 +95,7 @@ import org.thoughtcrime.securesms.migrations.StoryViewedReceiptsStateMigrationJo
import org.thoughtcrime.securesms.migrations.SubscriberIdMigrationJob;
import org.thoughtcrime.securesms.migrations.Svr2MirrorMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncCallLinksMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncChatFoldersMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncDistributionListsMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncKeysMigrationJob;
import org.thoughtcrime.securesms.migrations.TrimByLengthSettingsMigrationJob;
@@ -327,6 +328,7 @@ public final class JobManagerFactories {
put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory());
put(Svr2MirrorMigrationJob.KEY, new Svr2MirrorMigrationJob.Factory());
put(SyncCallLinksMigrationJob.KEY, new SyncCallLinksMigrationJob.Factory());
put(SyncChatFoldersMigrationJob.KEY, new SyncChatFoldersMigrationJob.Factory());
put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory());
put(SyncKeysMigrationJob.KEY, new SyncKeysMigrationJob.Factory());
put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory());

View File

@@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logI
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -95,6 +98,18 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
inserts.add(accountRecord)
allNewStorageIds.add(accountRecord.id)
val oldChatFolderStorageIds = SignalDatabase.chatFolders.getStorageSyncIdsMap()
val newChatFolderStorageIds = generateChatFolderStorageIds(oldChatFolderStorageIds)
val newChatFolderInserts: List<SignalStorageRecord> = oldChatFolderStorageIds.keys
.mapNotNull {
val query = SqlUtil.buildQuery("${ChatFolderTable.CHAT_FOLDER_ID} = ?", it)
SignalDatabase.chatFolders.getChatFolder(query)
}
.map { record -> StorageSyncModels.localToRemoteRecord(record, newChatFolderStorageIds[record.chatFolderId]!!.raw) }
inserts.addAll(newChatFolderInserts)
allNewStorageIds.addAll(newChatFolderStorageIds.values)
val recordIkm: RecordIkm? = if (Recipient.self().storageServiceEncryptionV2Capability.isSupported) {
Log.i(TAG, "Generating and including a new recordIkm.")
RecordIkm.generate()
@@ -135,6 +150,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
SignalStore.svr.masterKeyForInitialDataRestore = null
SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds)
SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id))
SignalDatabase.chatFolders.applyStorageIdUpdates(newChatFolderStorageIds)
SignalDatabase.unknownStorageIds.deleteAll()
}
@@ -154,6 +170,16 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
return out
}
private fun generateChatFolderStorageIds(oldKeys: Map<ChatFolderId, StorageId>): Map<ChatFolderId, StorageId> {
val out: MutableMap<ChatFolderId, StorageId> = mutableMapOf()
for ((key, value) in oldKeys) {
out[key] = value.withNewBytes(StorageSyncHelper.generateKey())
}
return out
}
class Factory : Job.Factory<StorageForcePushJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): StorageForcePushJob {
return StorageForcePushJob(parameters)

View File

@@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.jobs
import android.content.Context
import com.annimon.stream.Stream
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.signal.libsignal.protocol.InvalidKeyException
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -16,6 +19,7 @@ import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.AccountRecordProcessor
import org.thoughtcrime.securesms.storage.CallLinkRecordProcessor
import org.thoughtcrime.securesms.storage.ChatFolderRecordProcessor
import org.thoughtcrime.securesms.storage.ContactRecordProcessor
import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor
import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor
@@ -31,6 +35,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.storage.SignalAccountRecord
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record
@@ -43,6 +48,7 @@ import org.whispersystems.signalservice.api.storage.StorageServiceRepository
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestIfDifferentVersionResult
import org.whispersystems.signalservice.api.storage.toSignalAccountRecord
import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record
import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record
@@ -283,7 +289,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
db.beginTransaction()
try {
Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: ${remoteOnly.contacts.size}, GV1: ${remoteOnly.gv1.size}, GV2: ${remoteOnly.gv2.size}, Account: ${remoteOnly.account.size}, DLists: ${remoteOnly.storyDistributionLists.size}, call links: ${remoteOnly.callLinkRecords.size}")
Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: ${remoteOnly.contacts.size}, GV1: ${remoteOnly.gv1.size}, GV2: ${remoteOnly.gv2.size}, Account: ${remoteOnly.account.size}, DLists: ${remoteOnly.storyDistributionLists.size}, call links: ${remoteOnly.callLinkRecords.size}, chat folders: ${remoteOnly.chatFolderRecords.size}")
processKnownRecords(context, remoteOnly)
@@ -420,11 +426,13 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
AccountRecordProcessor(context, freshSelf()).process(records.account, StorageSyncHelper.KEY_GENERATOR)
StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR)
CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR)
ChatFolderRecordProcessor().process(records.chatFolderRecords, StorageSyncHelper.KEY_GENERATOR)
}
private fun getAllLocalStorageIds(self: Recipient): List<StorageId> {
return SignalDatabase.recipients.getContactStorageSyncIds() +
listOf(StorageId.forAccount(self.storageId)) +
SignalDatabase.chatFolders.getStorageSyncIds() +
SignalDatabase.unknownStorageIds.allUnknownIds
}
@@ -488,6 +496,16 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
}
}
ManifestRecord.Identifier.Type.CHAT_FOLDER -> {
val query = SqlUtil.buildQuery("${ChatFolderTable.STORAGE_SERVICE_ID} = ?", Base64.encodeWithPadding(id.raw))
val chatFolderRecord = SignalDatabase.chatFolders.getChatFolder(query)
if (chatFolderRecord?.chatFolderId != null) {
records.add(StorageSyncModels.localToRemoteRecord(chatFolderRecord, id.raw))
} else {
throw MissingChatFolderModelError("Missing local chat folder model!")
}
}
else -> {
val unknown = SignalDatabase.unknownStorageIds.getById(id.raw)
if (unknown != null) {
@@ -521,6 +539,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
val unknown: MutableList<SignalStorageRecord> = mutableListOf()
val storyDistributionLists: MutableList<SignalStoryDistributionListRecord> = mutableListOf()
val callLinkRecords: MutableList<SignalCallLinkRecord> = mutableListOf()
val chatFolderRecords: MutableList<SignalChatFolderRecord> = mutableListOf()
init {
for (record in records) {
@@ -536,6 +555,8 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
storyDistributionLists += record.proto.storyDistributionList!!.toSignalStoryDistributionListRecord(record.id)
} else if (record.proto.callLink != null) {
callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id)
} else if (record.proto.chatFolder != null) {
chatFolderRecords += record.proto.chatFolder!!.toSignalChatFolderRecord(record.id)
} else if (record.id.isUnknown) {
unknown += record
} else {
@@ -549,6 +570,8 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
private class MissingRecipientModelError(message: String?) : Error(message)
private class MissingChatFolderModelError(message: String?) : Error(message)
private class MissingUnknownModelError(message: String?) : Error(message)
class Factory : Job.Factory<StorageSyncJob?> {

View File

@@ -176,9 +176,10 @@ public class ApplicationMigrations {
static final int AVATAR_COLOR_MIGRATION_JOB = 132;
static final int DUPLICATE_E164_FIX_2 = 133;
static final int E164_FORMATTING = 134;
static final int CHAT_FOLDER_STORAGE_SYNC = 135;
}
public static final int CURRENT_VERSION = 134;
public static final int CURRENT_VERSION = 135;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -813,6 +814,10 @@ public class ApplicationMigrations {
jobs.put(Version.E164_FORMATTING, new E164FormattingMigrationJob());
}
if (lastSeenVersion < Version.CHAT_FOLDER_STORAGE_SYNC) {
jobs.put(Version.CHAT_FOLDER_STORAGE_SYNC, new SyncChatFoldersMigrationJob());
}
return jobs;
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.migrations
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.select
import org.thoughtcrime.securesms.database.ChatFolderTables
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/**
* Marks all chat folders as needing to be synced for storage service.
*/
internal class SyncChatFoldersMigrationJob(parameters: Parameters = Parameters.Builder().build()) : MigrationJob(parameters) {
companion object {
const val KEY = "SyncChatFoldersMigrationJob"
private val TAG = Log.tag(SyncChatFoldersMigrationJob::class)
}
override fun getFactoryKey(): String = KEY
override fun isUiBlocking(): Boolean = false
override fun performMigration() {
if (SignalStore.account.aci == null) {
Log.w(TAG, "Self not available yet.")
return
}
val folderIds = SignalDatabase.chatFolders.getAllFoldersForSync()
SignalDatabase.chatFolders.markNeedsSync(folderIds)
StorageSyncHelper.scheduleSyncForDataChange()
}
override fun shouldRetry(e: Exception): Boolean = false
private fun ChatFolderTables.getAllFoldersForSync(): List<Long> {
return readableDatabase
.select(ChatFolderTable.ID)
.from(ChatFolderTable.TABLE_NAME)
.run()
.readToList { cursor -> cursor.requireLong(ChatFolderTable.ID) }
}
class Factory : Job.Factory<SyncChatFoldersMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): SyncChatFoldersMigrationJob {
return SyncChatFoldersMigrationJob(parameters)
}
}
}

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.storage
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
import org.thoughtcrime.securesms.database.ChatFolderTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord
import java.util.Optional
import java.util.UUID
/**
* Record processor for [SignalChatFolderRecord].
* Handles merging and updating our local store when processing remote chat folder storage records.
*/
class ChatFolderRecordProcessor : DefaultStorageRecordProcessor<SignalChatFolderRecord>() {
companion object {
private val TAG = Log.tag(ChatFolderRecordProcessor::class)
}
override fun compare(o1: SignalChatFolderRecord, o2: SignalChatFolderRecord): Int {
return if (o1.proto.identifier == o2.proto.identifier) {
0
} else {
1
}
}
/**
* Folders must have a valid identifier and known folder type
* Custom chat folders must have a name.
* If a folder is deleted, it must have a -1 position
* If a folder is not deleted, it must have a non-negative position
* All recipients must have a valid serviceId
*/
override fun isInvalid(remote: SignalChatFolderRecord): Boolean {
return UuidUtil.parseOrNull(remote.proto.identifier) == null ||
remote.proto.folderType == ChatFolderRecord.FolderType.UNKNOWN ||
(remote.proto.folderType == ChatFolderRecord.FolderType.CUSTOM && remote.proto.name.isEmpty()) ||
(remote.proto.deletedAtTimestampMs > 0 && remote.proto.position != -1) ||
(remote.proto.deletedAtTimestampMs == 0L && remote.proto.position < 0) ||
containsInvalidServiceId(remote.proto.includedRecipients) ||
containsInvalidServiceId(remote.proto.excludedRecipients)
}
override fun getMatching(remote: SignalChatFolderRecord, keyGenerator: StorageKeyGenerator): Optional<SignalChatFolderRecord> {
Log.d(TAG, "Attempting to get matching record...")
val uuid: UUID = UuidUtil.parseOrThrow(remote.proto.identifier)
val query = SqlUtil.buildQuery("${ChatFolderTables.ChatFolderTable.CHAT_FOLDER_ID} = ?", ChatFolderId.from(uuid))
val folder = SignalDatabase.chatFolders.getChatFolder(query)
return if (folder?.storageServiceId != null) {
StorageSyncModels.localToRemoteChatFolder(folder, folder.storageServiceId.raw).asOptional()
} else if (folder != null) {
Log.d(TAG, "Folder was missing a storage service id, generating one")
val storageId = StorageId.forChatFolder(keyGenerator.generate())
SignalDatabase.chatFolders.applyStorageIdUpdate(folder.chatFolderId, storageId)
StorageSyncModels.localToRemoteChatFolder(folder, storageId.raw).asOptional()
} else {
Log.d(TAG, "Could not find a matching record. Returning an empty.")
Optional.empty<SignalChatFolderRecord>()
}
}
/**
* A deleted record takes precedence over a non-deleted record
* while an earlier deletion takes precedence over a later deletion
*/
override fun merge(remote: SignalChatFolderRecord, local: SignalChatFolderRecord, keyGenerator: StorageKeyGenerator): SignalChatFolderRecord {
return if (remote.proto.deletedAtTimestampMs > 0 && local.proto.deletedAtTimestampMs > 0) {
if (remote.proto.deletedAtTimestampMs < local.proto.deletedAtTimestampMs) {
remote
} else {
local
}
} else if (remote.proto.deletedAtTimestampMs > 0) {
remote
} else if (local.proto.deletedAtTimestampMs > 0) {
local
} else {
remote
}
}
override fun insertLocal(record: SignalChatFolderRecord) {
SignalDatabase.chatFolders.insertChatFolderFromStorageSync(record)
}
override fun updateLocal(update: StorageRecordUpdate<SignalChatFolderRecord>) {
SignalDatabase.chatFolders.updateChatFolderFromStorageSync(update.new)
}
private fun containsInvalidServiceId(recipients: List<ChatFolderRecord.Recipient>): Boolean {
return recipients.any { recipient ->
recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null
}
}
}

View File

@@ -5,12 +5,14 @@ import okio.ByteString.Companion.toByteString
import org.signal.core.util.isNotEmpty
import org.signal.core.util.isNullOrEmpty
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.GroupTable.ShowAsStoryState
import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTable.RecipientType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.callLinks
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.distributionLists
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups
@@ -22,6 +24,7 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record
@@ -29,6 +32,7 @@ import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record
import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record
@@ -43,10 +47,10 @@ import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record
import java.util.Currency
import kotlin.math.max
import org.whispersystems.signalservice.internal.storage.protos.AvatarColor as RemoteAvatarColor
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolder
object StorageSyncModels {
@JvmStatic
fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord {
if (settings.storageId == null) {
throw AssertionError("Must have a storage key!")
@@ -55,7 +59,6 @@ object StorageSyncModels {
return localToRemoteRecord(settings, settings.storageId)
}
@JvmStatic
fun localToRemoteRecord(settings: RecipientRecord, groupMasterKey: GroupMasterKey): SignalStorageRecord {
if (settings.storageId == null) {
throw AssertionError("Must have a storage key!")
@@ -64,7 +67,6 @@ object StorageSyncModels {
return localToRemoteGroupV2(settings, settings.storageId, groupMasterKey).toSignalStorageRecord()
}
@JvmStatic
fun localToRemoteRecord(settings: RecipientRecord, rawStorageId: ByteArray): SignalStorageRecord {
return when (settings.recipientType) {
RecipientType.INDIVIDUAL -> localToRemoteContact(settings, rawStorageId).toSignalStorageRecord()
@@ -76,6 +78,10 @@ object StorageSyncModels {
}
}
fun localToRemoteRecord(folder: ChatFolderRecord, rawStorageId: ByteArray): SignalStorageRecord {
return localToRemoteChatFolder(folder, rawStorageId).toSignalStorageRecord()
}
@JvmStatic
fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode {
return when (phoneNumberPhoneNumberSharingMode) {
@@ -364,4 +370,48 @@ object StorageSyncModels {
AvatarColor.ON_SURFACE_VARIANT -> RemoteAvatarColor.A100
}
}
fun localToRemoteChatFolder(folder: ChatFolderRecord, rawStorageId: ByteArray?): SignalChatFolderRecord {
if (folder.chatFolderId == null) {
throw AssertionError("Chat folder must have a chat folder id.")
}
return SignalChatFolderRecord.newBuilder(folder.storageServiceProto).apply {
identifier = UuidUtil.toByteArray(folder.chatFolderId.uuid).toByteString()
name = folder.name
position = folder.position
showOnlyUnread = folder.showUnread
showMutedChats = folder.showMutedChats
includeAllIndividualChats = folder.showIndividualChats
includeAllGroupChats = folder.showGroupChats
folderType = when (folder.folderType) {
ChatFolderRecord.FolderType.ALL -> RemoteChatFolder.FolderType.ALL
ChatFolderRecord.FolderType.INDIVIDUAL,
ChatFolderRecord.FolderType.GROUP,
ChatFolderRecord.FolderType.UNREAD,
ChatFolderRecord.FolderType.CUSTOM -> RemoteChatFolder.FolderType.CUSTOM
}
includedRecipients = localToRemoteChatFolderRecipients(folder.includedChats)
excludedRecipients = localToRemoteChatFolderRecipients(folder.excludedChats)
deletedAtTimestampMs = folder.deletedTimestampMs
}.build().toSignalChatFolderRecord(StorageId.forChatFolder(rawStorageId))
}
private fun localToRemoteChatFolderRecipients(threadIds: List<Long>): List<RemoteChatFolder.Recipient> {
val recipientIds = SignalDatabase.threads.getRecipientIdsForThreadIds(threadIds)
return recipientIds.mapNotNull { id ->
val recipient = SignalDatabase.recipients.getRecordForSync(id) ?: throw AssertionError("Missing recipient for id")
when (recipient.recipientType) {
RecipientType.INDIVIDUAL -> {
RemoteChatFolder.Recipient(contact = RemoteChatFolder.Recipient.Contact(serviceId = recipient.serviceId?.toString() ?: "", e164 = recipient.e164 ?: ""))
}
RecipientType.GV1 -> {
RemoteChatFolder.Recipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString())
}
RecipientType.GV2 -> {
RemoteChatFolder.Recipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString())
}
else -> null
}
}
}
}

View File

@@ -151,6 +151,11 @@ public final class StorageSyncValidations {
throw new DuplicateCallLinkError();
}
ids = manifest.getStorageIdsByType().get(ManifestRecord.Identifier.Type.CHAT_FOLDER.getValue());
if (ids.size() != new HashSet<>(ids).size()) {
throw new DuplicateChatFolderError();
}
throw new DuplicateRawIdAcrossTypesError();
}
@@ -206,6 +211,9 @@ public final class StorageSyncValidations {
private static final class DuplicateCallLinkError extends Error {
}
private static final class DuplicateChatFolderError extends Error {
}
private static final class DuplicateInsertInWriteError extends Error {
}