Add notification profiles to storage service.

This commit is contained in:
Michelle Tang
2025-06-04 11:02:43 -04:00
committed by Cody Henthorne
parent 07d961fc09
commit e3ee3d3dba
27 changed files with 1132 additions and 152 deletions

View File

@@ -26,6 +26,7 @@ import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class)
class ChatFolderTablesTest {
@@ -168,11 +169,11 @@ class ChatFolderTablesTest {
folderType = RemoteChatFolderRecord.FolderType.CUSTOM,
deletedAtTimestampMs = folder1.deletedTimestampMs,
includedRecipients = listOf(
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(alice).serviceId.get().toString())),
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(bob).serviceId.get().toString()))
RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString())),
RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(bob).serviceId.get().toString()))
),
excludedRecipients = listOf(
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(charlie).serviceId.get().toString()))
RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(charlie).serviceId.get().toString()))
)
)

View File

@@ -0,0 +1,174 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.time.DayOfWeek
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class)
class NotificationProfileTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var alice: RecipientId
private lateinit var profile1: NotificationProfile
@Before
fun setUp() {
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
profile1 = NotificationProfile(
id = 1,
name = "profile1",
emoji = "",
createdAt = 1000L,
schedule = NotificationProfileSchedule(id = 1),
allowedMembers = setOf(alice),
notificationProfileId = NotificationProfileId.generate(),
deletedTimestampMs = 0,
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
}
@Test
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
allowAllMentions = false,
allowAllCalls = true,
scheduleEnabled = false,
scheduleStartTime = 900,
scheduleEndTime = 1700,
scheduleDaysEnabled = emptyList(),
deletedAtTimestampMs = 0
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
assertEquals(listOf(profile1), actualProfiles)
}
@Test
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
}
@Test
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
assertThat(deletedProfile.schedule.enabled).isFalse()
assertThat(deletedProfile.schedule.start).isEqualTo(900)
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
}
@Test
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
SignalDatabase.notificationProfiles.createProfile(
name = "Profile1",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
)
SignalDatabase.notificationProfiles.createProfile(
name = "Profile2",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 2000L
)
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, _) ->
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
}
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, storageId) ->
assertNotEquals(storageId, updatedMap[id])
}
}
@Test
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
deletedAtTimestampMs = 1000L
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
}
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
}

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.toInt
@@ -22,6 +23,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.serialize
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.util.UuidUtil
import java.time.DayOfWeek
import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as NotificationProfileProto
@@ -59,7 +61,8 @@ object NotificationProfileProcessor {
NotificationProfileTable.CREATED_AT to profile.createdAtMs,
NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls.toInt(),
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.toInt(),
NotificationProfileTable.NOTIFICATION_PROFILE_ID to notificationProfileUuid.toString()
NotificationProfileTable.NOTIFICATION_PROFILE_ID to notificationProfileUuid.toString(),
NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey())
)
.run()

View File

@@ -338,6 +338,12 @@ private fun StorageRecordRow(record: SignalStorageRecord) {
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.notificationProfile != null -> {
Column {
Text("Notification Profile", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
else -> {
Column {
Text("Unknown!")

View File

@@ -76,6 +76,7 @@ class EditNotificationProfileScheduleViewModel(
repository.updateSchedule(schedule)
.toSingleDefault(SaveScheduleResult.Success)
.flatMap { r ->
repository.scheduleNotificationProfileSync(profileId)
if (schedule.enabled && schedule.coversTime(System.currentTimeMillis())) {
repository.manuallyEnableProfileForSchedule(profileId, schedule)
.toSingleDefault(r)

View File

@@ -16,7 +16,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
@@ -56,36 +58,43 @@ class NotificationProfilesRepository {
fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> {
return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) }
.doOnSuccess { StorageSyncHelper.scheduleSyncForDataChange() }
.subscribeOn(Schedulers.io())
}
fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io())
}
fun updateProfile(profile: NotificationProfile): Single<NotificationProfileTables.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profile) }
.doOnSuccess { scheduleNotificationProfileSync(profile.id) }
.subscribeOn(Schedulers.io())
}
fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> {
return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io())
}
fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io())
}
fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io())
}
fun deleteProfile(profileId: Long): Completable {
return Completable.fromCallable { database.deleteProfile(profileId) }
.doOnComplete { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io())
}
@@ -132,7 +141,10 @@ class NotificationProfilesRepository {
SignalStore.notificationProfile.manuallyDisabledAt = now
}
}
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() }
.doOnComplete {
scheduleManualOverrideSync()
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
.subscribeOn(Schedulers.io())
}
@@ -142,7 +154,10 @@ class NotificationProfilesRepository {
SignalStore.notificationProfile.manuallyEnabledUntil = enableUntil
SignalStore.notificationProfile.manuallyDisabledAt = now
}
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() }
.doOnComplete {
scheduleManualOverrideSync()
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
.subscribeOn(Schedulers.io())
}
@@ -153,9 +168,28 @@ class NotificationProfilesRepository {
SignalStore.notificationProfile.manuallyEnabledUntil = if (inScheduledWindow) schedule.endDateTime(now.toLocalDateTime()).toMillis() else Long.MAX_VALUE
SignalStore.notificationProfile.manuallyDisabledAt = if (inScheduledWindow) now else 0
}
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() }
.doOnComplete {
scheduleManualOverrideSync()
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
.subscribeOn(Schedulers.io())
}
/**
* Schedules a sync for a notification profile when it changes
*/
fun scheduleNotificationProfileSync(profileId: Long) {
SignalDatabase.notificationProfiles.markNeedsSync(profileId)
StorageSyncHelper.scheduleSyncForDataChange()
}
/**
* Schedules a sync for the self when the manual notification profile changes
*/
private fun scheduleManualOverrideSync() {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
class NotificationProfileNotFoundException : Throwable()
}

View File

@@ -24,18 +24,12 @@ 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.thoughtcrime.securesms.storage.StorageSyncModels
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
@@ -684,10 +678,10 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
RemoteChatFolderRecord.FolderType.UNKNOWN -> throw AssertionError("Folder type cannot be unknown")
},
includedChats = record.proto.includedRecipients
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) }
.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
excludedChats = record.proto.excludedRecipients
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) }
.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
chatFolderId = chatFolderId,
storageServiceId = StorageId.forChatFolder(record.id.raw),
@@ -696,34 +690,6 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
)
}
/**
* 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

@@ -5,23 +5,40 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteConstraintException
import androidx.core.content.contentValuesOf
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.exists
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.readToSingleLong
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.toInt
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.storage.StorageSyncModels
import org.thoughtcrime.securesms.storage.StorageSyncModels.toLocal
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.days
/**
* Database for maintaining Notification Profiles, Notification Profile Schedules, and Notification Profile allowed memebers.
@@ -30,6 +47,7 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
companion object {
private val TAG = Log.tag(NotificationProfileTable::class)
private val DELETED_LIFESPAN: Long = 30.days.inWholeMilliseconds
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(NotificationProfileTable.CREATE_TABLE, NotificationProfileScheduleTable.CREATE_TABLE, NotificationProfileAllowedMembersTable.CREATE_TABLE)
@@ -49,17 +67,23 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
const val ALLOW_ALL_CALLS = "allow_all_calls"
const val ALLOW_ALL_MENTIONS = "allow_all_mentions"
const val NOTIFICATION_PROFILE_ID = "notification_profile_id"
const val DELETED_TIMESTAMP_MS = "deleted_timestamp_ms"
const val STORAGE_SERVICE_ID = "storage_service_id"
const val STORAGE_SERVICE_PROTO = "storage_service_proto"
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NAME TEXT NOT NULL UNIQUE,
$NAME TEXT NOT NULL,
$EMOJI TEXT NOT NULL,
$COLOR TEXT NOT NULL,
$CREATED_AT INTEGER NOT NULL,
$ALLOW_ALL_CALLS INTEGER NOT NULL DEFAULT 0,
$ALLOW_ALL_MENTIONS INTEGER NOT NULL DEFAULT 0,
$NOTIFICATION_PROFILE_ID TEXT DEFAULT NULL
$NOTIFICATION_PROFILE_ID TEXT DEFAULT NULL,
$DELETED_TIMESTAMP_MS INTEGER DEFAULT 0,
$STORAGE_SERVICE_ID TEXT DEFAULT NULL,
$STORAGE_SERVICE_PROTO TEXT DEFAULT NULL
)
"""
}
@@ -114,7 +138,12 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
db.beginTransaction()
try {
if (isDuplicateName(name)) {
return NotificationProfileChangeResult.DuplicateName
}
val notificationProfileId = NotificationProfileId.generate()
val storageServiceId = StorageSyncHelper.generateKey()
val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, name)
put(NotificationProfileTable.EMOJI, emoji)
@@ -122,12 +151,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
put(NotificationProfileTable.CREATED_AT, createdAt)
put(NotificationProfileTable.ALLOW_ALL_CALLS, 1)
put(NotificationProfileTable.NOTIFICATION_PROFILE_ID, notificationProfileId.serialize())
put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId))
}
val profileId = db.insert(NotificationProfileTable.TABLE_NAME, null, profileValues)
if (profileId < 0) {
return NotificationProfileChangeResult.DuplicateName
}
val scheduleValues = ContentValues().apply {
put(NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID, profileId)
@@ -147,7 +174,8 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
createdAt = createdAt,
schedule = getProfileSchedule(profileId),
allowAllCalls = true,
notificationProfileId = notificationProfileId
notificationProfileId = notificationProfileId,
storageServiceId = StorageId.forNotificationProfile(storageServiceId)
)
)
} finally {
@@ -157,6 +185,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
}
fun updateProfile(profileId: Long, name: String, emoji: String): NotificationProfileChangeResult {
if (isDuplicateName(name, profileId)) {
return NotificationProfileChangeResult.DuplicateName
}
val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, name)
put(NotificationProfileTable.EMOJI, emoji)
@@ -164,37 +196,38 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profileId), profileValues)
return try {
val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
if (count > 0) {
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
NotificationProfileChangeResult.Success(getProfile(profileId)!!)
} catch (e: SQLiteConstraintException) {
NotificationProfileChangeResult.DuplicateName
}
return NotificationProfileChangeResult.Success(getProfile(profileId)!!)
}
fun updateProfile(profile: NotificationProfile): NotificationProfileChangeResult {
if (isDuplicateName(profile.name, profile.id)) {
return NotificationProfileChangeResult.DuplicateName
}
val db = writableDatabase
db.beginTransaction()
try {
val storageServiceId = profile.storageServiceId?.raw ?: StorageSyncHelper.generateKey()
val storageServiceProto = if (profile.storageServiceProto != null) Base64.encodeWithPadding(profile.storageServiceProto) else null
val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, profile.name)
put(NotificationProfileTable.EMOJI, profile.emoji)
put(NotificationProfileTable.ALLOW_ALL_CALLS, profile.allowAllCalls.toInt())
put(NotificationProfileTable.ALLOW_ALL_MENTIONS, profile.allowAllMentions.toInt())
put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId))
put(NotificationProfileTable.STORAGE_SERVICE_PROTO, storageServiceProto)
}
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profile.id), profileValues)
try {
db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
} catch (e: SQLiteConstraintException) {
return NotificationProfileChangeResult.DuplicateName
}
updateSchedule(profile.schedule, true)
@@ -280,16 +313,16 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
return getProfile(profileId)!!
}
/**
* Returns all undeleted notification profiles
*/
fun getProfiles(): List<NotificationProfile> {
val profiles: MutableList<NotificationProfile> = mutableListOf()
readableDatabase.query(NotificationProfileTable.TABLE_NAME, null, null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
profiles += getProfile(cursor)
}
}
return profiles
return readableDatabase
.select()
.from(NotificationProfileTable.TABLE_NAME)
.where("${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0")
.run()
.readToList { cursor -> getProfile(cursor) }
}
fun getProfile(profileId: Long): NotificationProfile? {
@@ -302,11 +335,196 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
}
}
fun getProfile(query: SqlUtil.Query): NotificationProfile? {
return readableDatabase
.select()
.from(NotificationProfileTable.TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToSingleObject { cursor -> getProfile(cursor) }
}
fun deleteProfile(profileId: Long) {
writableDatabase.delete(NotificationProfileTable.TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(profileId))
writableDatabase.withinTransaction { db ->
db.update(NotificationProfileTable.TABLE_NAME)
.values(NotificationProfileTable.DELETED_TIMESTAMP_MS to System.currentTimeMillis())
.where("${NotificationProfileTable.ID} = ?", profileId)
.run()
}
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
fun markNeedsSync(profileId: Long) {
writableDatabase.withinTransaction {
rotateStorageId(profileId)
}
}
fun applyStorageIdUpdate(id: NotificationProfileId, storageId: StorageId) {
applyStorageIdUpdates(hashMapOf(id to storageId))
}
fun applyStorageIdUpdates(storageIds: Map<NotificationProfileId, StorageId>) {
writableDatabase.withinTransaction { db ->
storageIds.forEach { (notificationProfileId, storageId) ->
db.update(NotificationProfileTable.TABLE_NAME)
.values(NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId.raw))
.where("${NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", notificationProfileId.serialize())
.run()
}
}
}
fun insertNotificationProfileFromStorageSync(notificationProfileRecord: SignalNotificationProfileRecord) {
val profile = notificationProfileRecord.proto
writableDatabase.withinTransaction { db ->
val storageServiceProto = if (notificationProfileRecord.proto.hasUnknownFields()) Base64.encodeWithPadding(notificationProfileRecord.serializedUnknowns!!) else null
val id = db.insertInto(NotificationProfileTable.TABLE_NAME)
.values(
contentValuesOf(
NotificationProfileTable.NAME to profile.name,
NotificationProfileTable.EMOJI to profile.emoji.orEmpty(),
NotificationProfileTable.COLOR to (AvatarColor.fromColor(profile.color)?.serialize() ?: NotificationProfile.DEFAULT_NOTIFICATION_PROFILE_COLOR.serialize()),
NotificationProfileTable.CREATED_AT to profile.createdAtMs,
NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls,
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions,
NotificationProfileTable.NOTIFICATION_PROFILE_ID to NotificationProfileId(UuidUtil.parseOrThrow(profile.id)).serialize(),
NotificationProfileTable.DELETED_TIMESTAMP_MS to profile.deletedAtTimestampMs,
NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(notificationProfileRecord.id.raw),
NotificationProfileTable.STORAGE_SERVICE_PROTO to storageServiceProto
)
)
.run()
db.insertInto(NotificationProfileScheduleTable.TABLE_NAME)
.values(
contentValuesOf(
NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID to id,
NotificationProfileScheduleTable.ENABLED to profile.scheduleEnabled.toInt(),
NotificationProfileScheduleTable.START to profile.scheduleStartTime,
NotificationProfileScheduleTable.END to profile.scheduleEndTime,
NotificationProfileScheduleTable.DAYS_ENABLED to profile.scheduleDaysEnabled.map { it.toLocal() }.toSet().serialize()
)
)
.run()
profile.allowedMembers
.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
.forEach {
db.insertInto(NotificationProfileAllowedMembersTable.TABLE_NAME)
.values(
NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID to id,
NotificationProfileAllowedMembersTable.RECIPIENT_ID to it.id.serialize()
)
.run()
}
}
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
fun updateNotificationProfileFromStorageSync(notificationProfileRecord: SignalNotificationProfileRecord) {
val profile = notificationProfileRecord.proto
val notificationProfileId = NotificationProfileId(UuidUtil.parseOrThrow(profile.id))
val profileId = readableDatabase
.select(NotificationProfileTable.ID)
.from(NotificationProfileTable.TABLE_NAME)
.where("${NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", notificationProfileId.serialize())
.run()
.readToSingleLong()
val scheduleId = readableDatabase
.select(NotificationProfileScheduleTable.ID)
.from(NotificationProfileScheduleTable.TABLE_NAME)
.where("${NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID} = ?", profileId)
.run()
.readToSingleLong()
updateProfile(
NotificationProfile(
id = profileId,
name = profile.name,
emoji = profile.emoji.orEmpty(),
color = AvatarColor.fromColor(profile.color) ?: NotificationProfile.DEFAULT_NOTIFICATION_PROFILE_COLOR,
createdAt = profile.createdAtMs,
allowAllCalls = profile.allowAllCalls,
allowAllMentions = profile.allowAllMentions,
schedule = NotificationProfileSchedule(
scheduleId,
profile.scheduleEnabled,
profile.scheduleStartTime,
profile.scheduleEndTime,
profile.scheduleDaysEnabled.map { it.toLocal() }.toSet()
),
allowedMembers = profile.allowedMembers.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient)?.id }.toSet(),
notificationProfileId = notificationProfileId,
deletedTimestampMs = profile.deletedAtTimestampMs,
storageServiceId = StorageId.forNotificationProfile(notificationProfileRecord.id.raw),
storageServiceProto = notificationProfileRecord.serializedUnknowns
)
)
}
fun getStorageSyncIdsMap(): Map<NotificationProfileId, StorageId> {
return readableDatabase
.select(NotificationProfileTable.NOTIFICATION_PROFILE_ID, NotificationProfileTable.STORAGE_SERVICE_ID)
.from(NotificationProfileTable.TABLE_NAME)
.where("${NotificationProfileTable.STORAGE_SERVICE_ID} IS NOT NULL")
.run()
.readToMap { cursor ->
val id = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID))
val encodedKey = cursor.requireNonNullString(NotificationProfileTable.STORAGE_SERVICE_ID)
val key = Base64.decodeOrThrow(encodedKey)
id to StorageId.forNotificationProfile(key)
}
}
fun getStorageSyncIds(): List<StorageId> {
return readableDatabase
.select(NotificationProfileTable.STORAGE_SERVICE_ID)
.from(NotificationProfileTable.TABLE_NAME)
.where("${NotificationProfileTable.STORAGE_SERVICE_ID} IS NOT NULL")
.run()
.readToList { cursor ->
val encodedKey = cursor.requireNonNullString(NotificationProfileTable.STORAGE_SERVICE_ID)
val key = Base64.decodeOrThrow(encodedKey)
StorageId.forNotificationProfile(key)
}.also { Log.i(TAG, "${it.size} profiles have storage ids.") }
}
/**
* Removes storageIds from notification profiles that have been deleted for [DELETED_LIFESPAN].
*/
fun removeStorageIdsFromOldDeletedProfiles(now: Long): Int {
return writableDatabase
.update(NotificationProfileTable.TABLE_NAME)
.values(NotificationProfileTable.STORAGE_SERVICE_ID to null)
.where("${NotificationProfileTable.STORAGE_SERVICE_ID} NOT NULL AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} < ?", now - DELETED_LIFESPAN)
.run()
}
/**
* Removes storageIds of profiles that are local only and deleted
*/
fun removeStorageIdsFromLocalOnlyDeletedProfiles(storageIds: Collection<StorageId>): Int {
var updated = 0
SqlUtil.buildCollectionQuery(NotificationProfileTable.STORAGE_SERVICE_ID, storageIds.map { Base64.encodeWithPadding(it.raw) }, "${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND")
.forEach {
updated += writableDatabase.update(
NotificationProfileTable.TABLE_NAME,
contentValuesOf(NotificationProfileTable.STORAGE_SERVICE_ID to null),
it.where,
it.whereArgs
)
}
return updated
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
val count = writableDatabase
.update(NotificationProfileAllowedMembersTable.TABLE_NAME)
@@ -331,7 +549,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
allowAllMentions = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_MENTIONS),
schedule = getProfileSchedule(profileId),
allowedMembers = getProfileAllowedMembers(profileId),
notificationProfileId = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID))
notificationProfileId = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID)),
deletedTimestampMs = cursor.requireLong(NotificationProfileTable.DELETED_TIMESTAMP_MS),
storageServiceId = cursor.requireString(NotificationProfileTable.STORAGE_SERVICE_ID)?.let { StorageId.forNotificationProfile(Base64.decodeNullableOrThrow(it)) },
storageServiceProto = Base64.decodeOrNull(cursor.requireString(NotificationProfileTable.STORAGE_SERVICE_PROTO))
)
}
@@ -372,6 +593,24 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
return allowed
}
private fun rotateStorageId(id: Long) {
writableDatabase
.update(NotificationProfileTable.TABLE_NAME)
.values(NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
.where("${NotificationProfileTable.ID} = ?", id)
.run()
}
/**
* Checks that there is no other notification profile with the same [name]
*/
private fun isDuplicateName(name: String, id: Long = -1): Boolean {
return readableDatabase
.exists(NotificationProfileTable.TABLE_NAME)
.where("${NotificationProfileTable.NAME} = ? AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0 AND ${NotificationProfileTable.ID} != ?", name, id)
.run()
}
sealed class NotificationProfileChangeResult {
data class Success(val notificationProfile: NotificationProfile) : NotificationProfileChangeResult()
object DuplicateName : NotificationProfileChangeResult()

View File

@@ -131,6 +131,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V273_FixUnreadOrigi
import org.thoughtcrime.securesms.database.helpers.migration.V274_BackupMediaSnapshotLastSeenOnRemote
import org.thoughtcrime.securesms.database.helpers.migration.V275_EnsureDefaultAllChatsFolder
import org.thoughtcrime.securesms.database.helpers.migration.V276_AttachmentCdnDefaultValueMigration
import org.thoughtcrime.securesms.database.helpers.migration.V277_AddNotificationProfileStorageSync
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -267,10 +268,11 @@ object SignalDatabaseMigrations {
273 to V273_FixUnreadOriginalMessages,
274 to V274_BackupMediaSnapshotLastSeenOnRemote,
275 to V275_EnsureDefaultAllChatsFolder,
276 to V276_AttachmentCdnDefaultValueMigration
276 to V276_AttachmentCdnDefaultValueMigration,
277 to V277_AddNotificationProfileStorageSync
)
const val DATABASE_VERSION = 276
const val DATABASE_VERSION = 277
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.signal.core.util.Base64
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.thoughtcrime.securesms.database.SQLiteDatabase
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/**
* Adds columns to notification profiles to support storage service, drops names unique constraint, sets all profiles with a storage service id.
*/
object V277_AddNotificationProfileStorageSync : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// Rebuild table to drop unique constraint on 'name'
db.execSQL(
"""
CREATE TABLE notification_profile_temp (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
emoji TEXT NOT NULL,
color TEXT NOT NULL,
created_at INTEGER NOT NULL,
allow_all_calls INTEGER NOT NULL DEFAULT 0,
allow_all_mentions INTEGER NOT NULL DEFAULT 0,
notification_profile_id TEXT DEFAULT NULL,
deleted_timestamp_ms INTEGER DEFAULT 0,
storage_service_id TEXT DEFAULT NULL,
storage_service_proto TEXT DEFAULT NULL
)
""".trimIndent()
)
db.execSQL("INSERT INTO notification_profile_temp (_id, name, emoji, color, created_at, allow_all_calls, allow_all_mentions, notification_profile_id) SELECT _id, name, emoji, color, created_at, allow_all_calls, allow_all_mentions, notification_profile_id FROM notification_profile")
db.execSQL("DROP TABLE notification_profile")
db.execSQL("ALTER TABLE notification_profile_temp RENAME TO notification_profile")
// Initialize all profiles with a storage service id
db.rawQuery("SELECT _id FROM notification_profile")
.readToList { it.requireLong("_id") }
.forEach { id ->
db.execSQL("UPDATE notification_profile SET storage_service_id = '${Base64.encodeWithPadding(StorageSyncHelper.generateKey())}' WHERE _id = $id")
}
}
}

View File

@@ -5,12 +5,14 @@ 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.NotificationProfileTables
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -110,6 +112,18 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
inserts.addAll(newChatFolderInserts)
allNewStorageIds.addAll(newChatFolderStorageIds.values)
val oldNotificationProfileStorageIds = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
val newNotificationProfileStorageIds = generateNotificationProfileStorageIds(oldNotificationProfileStorageIds)
val newNotificationProfileInserts: List<SignalStorageRecord> = oldNotificationProfileStorageIds.keys
.mapNotNull {
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", it)
SignalDatabase.notificationProfiles.getProfile(query)
}
.map { record -> StorageSyncModels.localToRemoteRecord(record, newNotificationProfileStorageIds[record.notificationProfileId]!!.raw) }
inserts.addAll(newNotificationProfileInserts)
allNewStorageIds.addAll(newNotificationProfileStorageIds.values)
val recordIkm: RecordIkm? = if (Recipient.self().storageServiceEncryptionV2Capability.isSupported) {
Log.i(TAG, "Generating and including a new recordIkm.")
RecordIkm.generate()
@@ -151,6 +165,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds)
SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id))
SignalDatabase.chatFolders.applyStorageIdUpdates(newChatFolderStorageIds)
SignalDatabase.notificationProfiles.applyStorageIdUpdates(newNotificationProfileStorageIds)
SignalDatabase.unknownStorageIds.deleteAll()
}
@@ -180,6 +195,12 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
return out
}
private fun generateNotificationProfileStorageIds(oldKeys: Map<NotificationProfileId, StorageId>): Map<NotificationProfileId, StorageId> {
return oldKeys.mapValues { (_, value) ->
value.withNewBytes(StorageSyncHelper.generateKey())
}
}
class Factory : Job.Factory<StorageForcePushJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): StorageForcePushJob {
return StorageForcePushJob(parameters)

View File

@@ -9,6 +9,7 @@ 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.NotificationProfileTables
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.storage.ChatFolderRecordProcessor
import org.thoughtcrime.securesms.storage.ContactRecordProcessor
import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor
import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor
import org.thoughtcrime.securesms.storage.NotificationProfileRecordProcessor
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult
import org.thoughtcrime.securesms.storage.StorageSyncModels
@@ -40,6 +42,7 @@ 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
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
@@ -53,6 +56,7 @@ 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
import org.whispersystems.signalservice.api.storage.toSignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord
@@ -279,9 +283,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
if (idDifference.localOnlyIds.isNotEmpty()) {
val updatedRecipients = SignalDatabase.recipients.removeStorageIdsFromLocalOnlyUnregisteredRecipients(idDifference.localOnlyIds)
val updatedFolders = SignalDatabase.chatFolders.removeStorageIdsFromLocalOnlyDeletedFolders(idDifference.localOnlyIds)
val updatedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromLocalOnlyDeletedProfiles(idDifference.localOnlyIds)
if (updatedRecipients > 0 || updatedFolders > 0) {
Log.w(TAG, "Found $updatedRecipients recipients and $updatedFolders folders that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.")
if (updatedRecipients > 0 || updatedFolders > 0 || updatedProfiles > 0) {
Log.w(TAG, "Found $updatedRecipients recipients, $updatedFolders folders, $updatedProfiles notification profiles that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.")
localStorageIdsBeforeMerge = getAllLocalStorageIds(self)
idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge)
@@ -310,7 +315,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
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}, chat folders: ${remoteOnly.chatFolderRecords.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}, notification profiles: ${remoteOnly.notificationProfileRecords.size}")
processKnownRecords(context, remoteOnly)
@@ -352,13 +357,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
self = freshSelf()
val removedUnregistered = SignalDatabase.recipients.removeStorageIdsFromOldUnregisteredRecipients(System.currentTimeMillis())
if (removedUnregistered > 0) {
Log.i(TAG, "Removed $removedUnregistered recipients from storage service that have been unregistered for longer than 30 days.")
}
val removedDeletedFolders = SignalDatabase.chatFolders.removeStorageIdsFromOldDeletedFolders(System.currentTimeMillis())
if (removedDeletedFolders > 0) {
Log.i(TAG, "Removed $removedDeletedFolders folders from storage service that have been deleted for longer than 30 days.")
val removedDeletedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
if (removedUnregistered > 0 || removedDeletedFolders > 0 || removedDeletedProfiles > 0) {
Log.i(TAG, "Removed $removedUnregistered unregistered, $removedDeletedFolders folders, $removedDeletedProfiles notification profiles from storage service that have been deleted for longer than 30 days.")
}
val localStorageIds = getAllLocalStorageIds(self)
@@ -454,12 +456,14 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR)
CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR)
ChatFolderRecordProcessor().process(records.chatFolderRecords, StorageSyncHelper.KEY_GENERATOR)
NotificationProfileRecordProcessor().process(records.notificationProfileRecords, StorageSyncHelper.KEY_GENERATOR)
}
private fun getAllLocalStorageIds(self: Recipient): List<StorageId> {
return SignalDatabase.recipients.getContactStorageSyncIds() +
listOf(StorageId.forAccount(self.storageId)) +
SignalDatabase.chatFolders.getStorageSyncIds() +
SignalDatabase.notificationProfiles.getStorageSyncIds() +
SignalDatabase.unknownStorageIds.allUnknownIds
}
@@ -533,6 +537,16 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
}
}
ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> {
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.STORAGE_SERVICE_ID} = ?", Base64.encodeWithPadding(id.raw))
val notificationProfile = SignalDatabase.notificationProfiles.getProfile(query)
if (notificationProfile?.notificationProfileId != null) {
records.add(StorageSyncModels.localToRemoteRecord(notificationProfile, id.raw))
} else {
throw MissingNotificationProfileModelError("Missing local notification profile model! Type: " + id.type)
}
}
else -> {
val unknown = SignalDatabase.unknownStorageIds.getById(id.raw)
if (unknown != null) {
@@ -567,6 +581,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
val storyDistributionLists: MutableList<SignalStoryDistributionListRecord> = mutableListOf()
val callLinkRecords: MutableList<SignalCallLinkRecord> = mutableListOf()
val chatFolderRecords: MutableList<SignalChatFolderRecord> = mutableListOf()
val notificationProfileRecords: MutableList<SignalNotificationProfileRecord> = mutableListOf()
init {
for (record in records) {
@@ -584,6 +599,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id)
} else if (record.proto.chatFolder != null) {
chatFolderRecords += record.proto.chatFolder!!.toSignalChatFolderRecord(record.id)
} else if (record.proto.notificationProfile != null) {
notificationProfileRecords += record.proto.notificationProfile!!.toSignalNotificationProfileRecord(record.id)
} else if (record.id.isUnknown) {
unknown += record
} else {
@@ -599,6 +616,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
private class MissingChatFolderModelError(message: String?) : Error(message)
private class MissingNotificationProfileModelError(message: String?) : Error(message)
private class MissingUnknownModelError(message: String?) : Error(message)
class Factory : Job.Factory<StorageSyncJob?> {

View File

@@ -2,20 +2,28 @@ package org.thoughtcrime.securesms.notifications.profiles
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.storage.StorageId
data class NotificationProfile(
val id: Long,
val name: String,
val emoji: String,
val color: AvatarColor = AvatarColor.A210,
val color: AvatarColor = DEFAULT_NOTIFICATION_PROFILE_COLOR,
val createdAt: Long,
val allowAllCalls: Boolean = true,
val allowAllMentions: Boolean = false,
val schedule: NotificationProfileSchedule,
val allowedMembers: Set<RecipientId> = emptySet(),
val notificationProfileId: NotificationProfileId
val notificationProfileId: NotificationProfileId,
val deletedTimestampMs: Long = 0,
val storageServiceId: StorageId? = null,
val storageServiceProto: ByteArray? = null
) : Comparable<NotificationProfile> {
companion object {
val DEFAULT_NOTIFICATION_PROFILE_COLOR = AvatarColor.A210
}
fun isRecipientAllowed(id: RecipientId): Boolean {
return allowedMembers.contains(id)
}

View File

@@ -5,7 +5,6 @@ import okio.ByteString
import org.signal.core.util.isNotEmpty
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfEmpty
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
@@ -125,7 +124,6 @@ class AccountRecordProcessor(
preferContactAvatars = remote.proto.preferContactAvatars
universalExpireTimer = remote.proto.universalExpireTimer
primarySendsSms = false
e164 = if (SignalStore.account.isPrimaryDevice) local.proto.e164 else remote.proto.e164
preferredReactionEmoji = remote.proto.preferredReactionEmoji.takeIf { it.isNotEmpty() } ?: local.proto.preferredReactionEmoji
displayBadgesOnProfile = remote.proto.displayBadgesOnProfile
subscriptionManuallyCancelled = remote.proto.subscriptionManuallyCancelled
@@ -138,6 +136,7 @@ class AccountRecordProcessor(
hasCompletedUsernameOnboarding = remote.proto.hasCompletedUsernameOnboarding || local.proto.hasCompletedUsernameOnboarding
username = remote.proto.username
usernameLink = remote.proto.usernameLink
notificationProfileManualOverride = remote.proto.notificationProfileManualOverride
safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray())
safeSetSubscriber(donationSubscriberId, donationSubscriberCurrencyCode)

View File

@@ -11,6 +11,7 @@ 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 org.whispersystems.signalservice.internal.storage.protos.Recipient
import java.util.Optional
import java.util.UUID
@@ -96,7 +97,7 @@ class ChatFolderRecordProcessor : DefaultStorageRecordProcessor<SignalChatFolder
SignalDatabase.chatFolders.updateChatFolderFromStorageSync(update.new)
}
private fun containsInvalidServiceId(recipients: List<ChatFolderRecord.Recipient>): Boolean {
private fun containsInvalidServiceId(recipients: List<Recipient>): Boolean {
return recipients.any { recipient ->
recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null
}

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.storage
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.NotificationProfileTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
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.Recipient
import java.util.Optional
import java.util.UUID
/**
* Record processor for [SignalNotificationProfileRecord].
* Handles merging and updating our local store when processing remote notification profile storage records.
*/
class NotificationProfileRecordProcessor : DefaultStorageRecordProcessor<SignalNotificationProfileRecord>() {
companion object {
private val TAG = Log.tag(NotificationProfileRecordProcessor::class)
}
override fun compare(o1: SignalNotificationProfileRecord, o2: SignalNotificationProfileRecord): Int {
return if (o1.proto.id == o2.proto.id) {
0
} else {
1
}
}
/**
* Notification profiles must have a valid identifier
* Notification profiles must have a name
* All allowed members must have a valid serviceId
*/
override fun isInvalid(remote: SignalNotificationProfileRecord): Boolean {
return UuidUtil.parseOrNull(remote.proto.id) == null ||
remote.proto.name.isEmpty() ||
containsInvalidServiceId(remote.proto.allowedMembers)
}
override fun getMatching(remote: SignalNotificationProfileRecord, keyGenerator: StorageKeyGenerator): Optional<SignalNotificationProfileRecord> {
Log.d(TAG, "Attempting to get matching record...")
val uuid: UUID = UuidUtil.parseOrThrow(remote.proto.id)
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", NotificationProfileId(uuid))
val notificationProfile = SignalDatabase.notificationProfiles.getProfile(query)
return if (notificationProfile?.storageServiceId != null) {
StorageSyncModels.localToRemoteNotificationProfile(notificationProfile, notificationProfile.storageServiceId.raw).asOptional()
} else if (notificationProfile != null) {
Log.d(TAG, "Notification profile was missing a storage service id, generating one")
val storageId = StorageId.forNotificationProfile(keyGenerator.generate())
SignalDatabase.notificationProfiles.applyStorageIdUpdate(notificationProfile.notificationProfileId, storageId)
StorageSyncModels.localToRemoteNotificationProfile(notificationProfile, storageId.raw).asOptional()
} else {
Log.d(TAG, "Could not find a matching record. Returning an empty.")
Optional.empty<SignalNotificationProfileRecord>()
}
}
/**
* A deleted record takes precedence over a non-deleted record
* while an earlier deletion takes precedence over a later deletion
*/
override fun merge(remote: SignalNotificationProfileRecord, local: SignalNotificationProfileRecord, keyGenerator: StorageKeyGenerator): SignalNotificationProfileRecord {
val isRemoteDeleted = remote.proto.deletedAtTimestampMs > 0
val isLocalDeleted = local.proto.deletedAtTimestampMs > 0
return when {
isRemoteDeleted && isLocalDeleted -> if (remote.proto.deletedAtTimestampMs < local.proto.deletedAtTimestampMs) remote else local
isRemoteDeleted -> remote
isLocalDeleted -> local
else -> remote
}
}
override fun insertLocal(record: SignalNotificationProfileRecord) {
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(record)
}
override fun updateLocal(update: StorageRecordUpdate<SignalNotificationProfileRecord>) {
SignalDatabase.notificationProfiles.updateNotificationProfileFromStorageSync(update.new)
}
private fun containsInvalidServiceId(recipients: List<Recipient>): Boolean {
return recipients.any { recipient ->
recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null
}
}
}

View File

@@ -5,10 +5,12 @@ import androidx.annotation.VisibleForTesting
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64.encodeWithPadding
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.getSubscriber
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.isUserManuallyCancelled
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.setSubscriber
import org.thoughtcrime.securesms.database.NotificationProfileTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord
@@ -18,6 +20,7 @@ import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.payments.Entropy
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.Recipient.Companion.self
@@ -171,6 +174,7 @@ object StorageSyncHelper {
color = StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc.usernameQrCodeColorScheme)
)
}
notificationProfileManualOverride = getNotificationProfileManualOverride()
getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)?.let {
safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency?.currencyCode ?: "")
@@ -186,6 +190,24 @@ object StorageSyncHelper {
return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord()
}
private fun getNotificationProfileManualOverride(): AccountRecord.NotificationProfileManualOverride {
val profile = SignalDatabase.notificationProfiles.getProfile(SignalStore.notificationProfile.manuallyEnabledProfile)
return if (profile != null && profile.deletedTimestampMs == 0L) {
// From [StorageService.proto], end timestamp should be unset if no timespan was chosen in the UI
val endTimestamp = if (SignalStore.notificationProfile.manuallyEnabledUntil == Long.MAX_VALUE) 0 else SignalStore.notificationProfile.manuallyEnabledUntil
AccountRecord.NotificationProfileManualOverride(
enabled = AccountRecord.NotificationProfileManualOverride.ManuallyEnabled(
id = UuidUtil.toByteArray(profile.notificationProfileId.uuid).toByteString(),
endAtTimestampMs = endTimestamp
)
)
} else {
AccountRecord.NotificationProfileManualOverride(
disabledAtTimestampMs = SignalStore.notificationProfile.manuallyDisabledAt
)
}
}
@JvmStatic
fun applyAccountStorageSyncUpdates(context: Context, self: Recipient, updatedRecord: SignalAccountRecord, fetchProfile: Boolean) {
val localRecord = buildAccountRecord(context, self).let { it.proto.account!!.toSignalAccountRecord(it.id) }
@@ -252,6 +274,33 @@ object StorageSyncHelper {
SignalStore.misc.usernameQrCodeColorScheme = StorageSyncModels.remoteToLocalUsernameColor(update.new.proto.usernameLink!!.color)
}
if (update.new.proto.notificationProfileManualOverride != null) {
if (update.new.proto.notificationProfileManualOverride!!.enabled != null) {
val remoteProfile = update.new.proto.notificationProfileManualOverride!!.enabled!!
val remoteId = UuidUtil.parseOrNull(remoteProfile.id)
val remoteEndTime = if (remoteProfile.endAtTimestampMs == 0L) Long.MAX_VALUE else remoteProfile.endAtTimestampMs
if (remoteId == null) {
Log.w(TAG, "Remote notification profile id is not valid")
} else {
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", NotificationProfileId(remoteId))
val localProfile = SignalDatabase.notificationProfiles.getProfile(query)
if (localProfile == null) {
Log.w(TAG, "Unable to find local notification profile with given remote id")
} else {
SignalStore.notificationProfile.manuallyEnabledProfile = localProfile.id
SignalStore.notificationProfile.manuallyEnabledUntil = remoteEndTime
SignalStore.notificationProfile.manuallyDisabledAt = System.currentTimeMillis()
}
}
} else {
SignalStore.notificationProfile.manuallyEnabledProfile = 0
SignalStore.notificationProfile.manuallyEnabledUntil = 0
SignalStore.notificationProfile.manuallyDisabledAt = update.new.proto.notificationProfileManualOverride!!.disabledAtTimestampMs!!
}
}
}
@JvmStatic

View File

@@ -4,6 +4,8 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.isNotEmpty
import org.signal.core.util.isNullOrEmpty
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.InvalidInputException
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
@@ -20,14 +22,21 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.inAppPayment
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
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
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
import org.whispersystems.signalservice.api.storage.StorageId
@@ -36,6 +45,7 @@ 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
import org.whispersystems.signalservice.api.storage.toSignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.toSignalStorageRecord
import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
@@ -44,13 +54,18 @@ import org.whispersystems.signalservice.internal.storage.protos.AccountRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record
import java.time.DayOfWeek
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
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek as RemoteDayOfWeek
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
object StorageSyncModels {
private val TAG = Log.tag(StorageSyncModels::class.java)
fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord {
if (settings.storageId == null) {
throw AssertionError("Must have a storage key!")
@@ -82,6 +97,10 @@ object StorageSyncModels {
return localToRemoteChatFolder(folder, rawStorageId).toSignalStorageRecord()
}
fun localToRemoteRecord(profile: NotificationProfile, rawStorageId: ByteArray): SignalStorageRecord {
return localToRemoteNotificationProfile(profile, rawStorageId).toSignalStorageRecord()
}
@JvmStatic
fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode {
return when (phoneNumberPhoneNumberSharingMode) {
@@ -396,22 +415,96 @@ object StorageSyncModels {
}.build().toSignalChatFolderRecord(StorageId.forChatFolder(rawStorageId))
}
private fun localToRemoteChatFolderRecipients(threadIds: List<Long>): List<RemoteChatFolder.Recipient> {
fun localToRemoteNotificationProfile(profile: NotificationProfile, rawStorageId: ByteArray?): SignalNotificationProfileRecord {
return SignalNotificationProfileRecord.newBuilder(profile.storageServiceProto).apply {
id = UuidUtil.toByteArray(profile.notificationProfileId.uuid).toByteString()
name = profile.name
emoji = profile.emoji
color = profile.color.colorInt()
createdAtMs = profile.createdAt
allowAllCalls = profile.allowAllCalls
allowAllMentions = profile.allowAllMentions
allowedMembers = localToRemoteRecipients(profile.allowedMembers.toList())
scheduleEnabled = profile.schedule.enabled
scheduleStartTime = profile.schedule.start
scheduleEndTime = profile.schedule.end
scheduleDaysEnabled = localToRemoteDayOfWeek(profile.schedule.daysEnabled)
deletedAtTimestampMs = profile.deletedTimestampMs
}.build().toSignalNotificationProfileRecord(StorageId.forNotificationProfile(rawStorageId))
}
private fun localToRemoteDayOfWeek(daysEnabled: Set<DayOfWeek>): List<RemoteDayOfWeek> {
return daysEnabled.map { day ->
when (day) {
DayOfWeek.MONDAY -> RemoteDayOfWeek.MONDAY
DayOfWeek.TUESDAY -> RemoteDayOfWeek.TUESDAY
DayOfWeek.WEDNESDAY -> RemoteDayOfWeek.WEDNESDAY
DayOfWeek.THURSDAY -> RemoteDayOfWeek.THURSDAY
DayOfWeek.FRIDAY -> RemoteDayOfWeek.FRIDAY
DayOfWeek.SATURDAY -> RemoteDayOfWeek.SATURDAY
DayOfWeek.SUNDAY -> RemoteDayOfWeek.SUNDAY
}
}
}
fun RemoteDayOfWeek.toLocal(): DayOfWeek {
return when (this) {
RemoteDayOfWeek.UNKNOWN -> DayOfWeek.MONDAY
RemoteDayOfWeek.MONDAY -> DayOfWeek.MONDAY
RemoteDayOfWeek.TUESDAY -> DayOfWeek.TUESDAY
RemoteDayOfWeek.WEDNESDAY -> DayOfWeek.WEDNESDAY
RemoteDayOfWeek.THURSDAY -> DayOfWeek.THURSDAY
RemoteDayOfWeek.FRIDAY -> DayOfWeek.FRIDAY
RemoteDayOfWeek.SATURDAY -> DayOfWeek.SATURDAY
RemoteDayOfWeek.SUNDAY -> DayOfWeek.SUNDAY
}
}
private fun localToRemoteChatFolderRecipients(threadIds: List<Long>): List<RemoteRecipient> {
val recipientIds = SignalDatabase.threads.getRecipientIdsForThreadIds(threadIds)
return localToRemoteRecipients(recipientIds)
}
private fun localToRemoteRecipients(recipientIds: List<RecipientId>): List<RemoteRecipient> {
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 ?: ""))
RemoteRecipient(contact = RemoteRecipient.Contact(serviceId = recipient.serviceId?.toString() ?: "", e164 = recipient.e164 ?: ""))
}
RecipientType.GV1 -> {
RemoteChatFolder.Recipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString())
RemoteRecipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString())
}
RecipientType.GV2 -> {
RemoteChatFolder.Recipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString())
RemoteRecipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString())
}
else -> null
}
}
}
fun remoteToLocalRecipient(remoteRecipient: RemoteRecipient): 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
}
}
}

View File

@@ -156,6 +156,11 @@ public final class StorageSyncValidations {
throw new DuplicateChatFolderError();
}
ids = manifest.getStorageIdsByType().get(ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue());
if (ids.size() != new HashSet<>(ids).size()) {
throw new DuplicateNotificationProfileError();
}
throw new DuplicateRawIdAcrossTypesError();
}
@@ -217,6 +222,9 @@ public final class StorageSyncValidations {
private static final class DuplicateInsertInWriteError extends Error {
}
private static final class DuplicateNotificationProfileError extends Error {
}
private static final class InsertNotPresentInFullIdSetError extends Error {
}

View File

@@ -52,6 +52,9 @@ class StorageServicePlugin : Plugin {
} else if (record.proto.chatFolder != null) {
row += "Chat Folder"
row += record.proto.chatFolder.toString()
} else if (record.proto.notificationProfile != null) {
row += "Notification Profile"
row += record.proto.notificationProfile.toString()
} else {
row += "Unknown"
row += ""

View File

@@ -11,6 +11,7 @@ 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
import org.whispersystems.signalservice.internal.storage.protos.Recipient
import java.util.UUID
/**
@@ -203,7 +204,7 @@ class ChatFolderRecordProcessorTest {
includeAllGroupChats = false
folderType = ChatFolderRecord.FolderType.CUSTOM
deletedAtTimestampMs = 0L
includedRecipients = listOf(ChatFolderRecord.Recipient(contact = ChatFolderRecord.Recipient.Contact(serviceId = "bad")))
includedRecipients = listOf(Recipient(contact = Recipient.Contact(serviceId = "bad")))
}.build()
val record = SignalChatFolderRecord(STORAGE_ID, proto)

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.storage
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testutil.EmptyLogger
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.Recipient
import java.util.UUID
/**
* Tests for [NotificationProfileRecordProcessor]
*/
class NotificationProfileRecordProcessorTest {
companion object {
val STORAGE_ID: StorageId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3, 4))
@JvmStatic
@BeforeClass
fun setUpClass() {
Log.initialize(EmptyLogger())
}
}
private val testSubject = NotificationProfileRecordProcessor()
@Test
fun `Given a valid proto with a known name and id, assert valid`() {
// GIVEN
val proto = NotificationProfile.Builder().apply {
id = UuidUtil.toByteArray(UUID.randomUUID()).toByteString()
name = "name"
}.build()
val record = SignalNotificationProfileRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertFalse(result)
}
@Test
fun `Given a valid proto with a deleted timestamp, known name and id, assert valid`() {
// GIVEN
val proto = NotificationProfile.Builder().apply {
id = UuidUtil.toByteArray(UUID.randomUUID()).toByteString()
name = "name"
deletedAtTimestampMs = 1000L
}.build()
val record = SignalNotificationProfileRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertFalse(result)
}
@Test
fun `Given an invalid proto with no id, assert invalid`() {
// GIVEN
val proto = NotificationProfile.Builder().apply {
id = "Bad".toByteArray().toByteString()
name = "Profile"
deletedAtTimestampMs = 0L
}.build()
val record = SignalNotificationProfileRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertTrue(result)
}
@Test
fun `Given an invalid proto with no name, assert invalid`() {
// GIVEN
val proto = NotificationProfile.Builder().apply {
id = UuidUtil.toByteArray(UUID.randomUUID()).toByteString()
name = ""
deletedAtTimestampMs = 0L
}.build()
val record = SignalNotificationProfileRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertTrue(result)
}
@Test
fun `Given an invalid proto with a member that does not have a service id, assert invalid`() {
// GIVEN
val proto = NotificationProfile.Builder().apply {
id = UuidUtil.toByteArray(UUID.randomUUID()).toByteString()
name = "Profile"
allowedMembers = listOf(Recipient(contact = Recipient.Contact(serviceId = "bad")))
}.build()
val record = SignalNotificationProfileRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertTrue(result)
}
}

View File

@@ -0,0 +1,27 @@
package org.whispersystems.signalservice.api.storage
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile
import java.io.IOException
/**
* Wrapper around a [NotificationProfile] to pair it with a [StorageId].
*/
data class SignalNotificationProfileRecord(
override val id: StorageId,
override val proto: NotificationProfile
) : SignalRecord<NotificationProfile> {
companion object {
fun newBuilder(serializedUnknowns: ByteArray?): NotificationProfile.Builder {
return serializedUnknowns?.let { builderFromUnknowns(it) } ?: NotificationProfile.Builder()
}
private fun builderFromUnknowns(serializedUnknowns: ByteArray): NotificationProfile.Builder {
return try {
NotificationProfile.ADAPTER.decode(serializedUnknowns).newBuilder()
} catch (e: IOException) {
NotificationProfile.Builder()
}
}
}
}

View File

@@ -10,7 +10,7 @@ data class SignalStorageRecord(
val proto: StorageRecord
) {
val isUnknown: Boolean
get() = proto.contact == null && proto.groupV1 == null && proto.groupV2 == null && proto.account == null && proto.storyDistributionList == null && proto.callLink == null && proto.chatFolder == null
get() = proto.contact == null && proto.groupV1 == null && proto.groupV2 == null && proto.account == null && proto.storyDistributionList == null && proto.callLink == null && proto.chatFolder == null && proto.notificationProfile == null
companion object {
@JvmStatic

View File

@@ -42,6 +42,10 @@ public class StorageId {
return new StorageId(ManifestRecord.Identifier.Type.CHAT_FOLDER.getValue(), Preconditions.checkNotNull(raw));
}
public static StorageId forNotificationProfile(byte[] raw) {
return new StorageId(ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue(), Preconditions.checkNotNull(raw));
}
public static StorageId forType(byte[] raw, int type) {
return new StorageId(type, raw);
}

View File

@@ -11,6 +11,7 @@ import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord
import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord
@@ -46,6 +47,10 @@ fun ChatFolderRecord.toSignalChatFolderRecord(storageId: StorageId): SignalChatF
return SignalChatFolderRecord(storageId, this)
}
fun NotificationProfile.toSignalNotificationProfileRecord(storageId: StorageId): SignalNotificationProfileRecord {
return SignalNotificationProfileRecord(storageId, this)
}
fun SignalContactRecord.toSignalStorageRecord(): SignalStorageRecord {
return SignalStorageRecord(id, StorageRecord(contact = this.proto))
}
@@ -73,3 +78,7 @@ fun SignalCallLinkRecord.toSignalStorageRecord(): SignalStorageRecord {
fun SignalChatFolderRecord.toSignalStorageRecord(): SignalStorageRecord {
return SignalStorageRecord(id, StorageRecord(chatFolder = this.proto))
}
fun SignalNotificationProfileRecord.toSignalStorageRecord(): SignalStorageRecord {
return SignalStorageRecord(id, StorageRecord(notificationProfile = this.proto))
}

View File

@@ -52,6 +52,7 @@ message ManifestRecord {
STORY_DISTRIBUTION_LIST = 5;
CALL_LINK = 7;
CHAT_FOLDER = 8;
NOTIFICATION_PROFILE = 9;
}
bytes raw = 1;
@@ -74,6 +75,7 @@ message StorageRecord {
StoryDistributionListRecord storyDistributionList = 5;
CallLinkRecord callLink = 7;
ChatFolderRecord chatFolder = 8;
NotificationProfile notificationProfile = 9;
}
}
@@ -225,6 +227,26 @@ message AccountRecord {
}
}
message BackupTierHistory {
// See zkgroup for integer particular values. Unset if backups are not enabled.
optional uint64 backupTier = 1;
optional uint64 endedAtTimestamp = 2;
}
message NotificationProfileManualOverride {
message ManuallyEnabled {
bytes id = 1;
// This will be unset if no timespan was chosen in the UI.
uint64 endAtTimestampMs = 3;
}
oneof override {
uint64 disabledAtTimestampMs = 1;
ManuallyEnabled enabled = 2;
}
}
bytes profileKey = 1;
string givenName = 2;
string familyName = 3;
@@ -243,7 +265,7 @@ message AccountRecord {
Payments payments = 16;
uint32 universalExpireTimer = 17;
bool primarySendsSms = 18;
string e164 = 19;
reserved /* e164 */ 19;
repeated string preferredReactionEmoji = 20;
bytes subscriberId = 21;
string subscriberCurrencyCode = 22;
@@ -264,9 +286,11 @@ message AccountRecord {
reserved /* backupsSubscriberCurrencyCode */ 37;
reserved /* backupsSubscriptionManuallyCancelled */ 38;
optional bool hasBackup = 39; // Set to true after backups are enabled and one is uploaded.
optional uint64 backupTier = 40; // See zkgroup for integer particular values
optional uint64 backupTier = 40; // See zkgroup for integer particular values. Unset if backups are not enabled.
IAPSubscriberData backupSubscriberData = 41;
optional AvatarColor avatarColor = 42;
BackupTierHistory backupTierHistory = 43;
NotificationProfileManualOverride notificationProfileManualOverride = 44;
}
message StoryDistributionListRecord {
@@ -284,8 +308,7 @@ message CallLinkRecord {
uint64 deletedAtTimestampMs = 3;
}
message ChatFolderRecord {
message Recipient {
message Recipient {
message Contact {
string serviceId = 1;
string e164 = 2;
@@ -296,8 +319,9 @@ message ChatFolderRecord {
bytes legacyGroupId = 2;
bytes groupMasterKey = 3;
}
}
}
message ChatFolderRecord {
// Represents the default "All chats" folder record vs all other custom folders
enum FolderType {
UNKNOWN = 0;
@@ -317,3 +341,30 @@ message ChatFolderRecord {
repeated Recipient excludedRecipients = 10;
uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and includedRecipients should be empty
}
message NotificationProfile {
enum DayOfWeek {
UNKNOWN = 0; // Interpret as "Monday"
MONDAY = 1;
TUESDAY = 2;
WEDNESDAY = 3;
THURSDAY = 4;
FRIDAY = 5;
SATURDAY = 6;
SUNDAY = 7;
}
bytes id = 1;
string name = 2;
optional string emoji = 3;
fixed32 color = 4; // 0xAARRGGBB
uint64 createdAtMs = 5;
bool allowAllCalls = 6;
bool allowAllMentions = 7;
repeated Recipient allowedMembers = 8;
bool scheduleEnabled = 9;
uint32 scheduleStartTime = 10; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345)
uint32 scheduleEndTime = 11; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345)
repeated DayOfWeek scheduleDaysEnabled = 12;
uint64 deletedAtTimestampMs = 13;
}