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 org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ChatFolderTablesTest { class ChatFolderTablesTest {
@@ -168,11 +169,11 @@ class ChatFolderTablesTest {
folderType = RemoteChatFolderRecord.FolderType.CUSTOM, folderType = RemoteChatFolderRecord.FolderType.CUSTOM,
deletedAtTimestampMs = folder1.deletedTimestampMs, deletedAtTimestampMs = folder1.deletedTimestampMs,
includedRecipients = listOf( includedRecipients = listOf(
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(alice).serviceId.get().toString())), RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString())),
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(bob).serviceId.get().toString())) RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(bob).serviceId.get().toString()))
), ),
excludedRecipients = listOf( 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 package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.toByteString import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.insertInto import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.toInt 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.database.serialize
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.api.util.UuidUtil
import java.time.DayOfWeek import java.time.DayOfWeek
import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as NotificationProfileProto import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as NotificationProfileProto
@@ -59,7 +61,8 @@ object NotificationProfileProcessor {
NotificationProfileTable.CREATED_AT to profile.createdAtMs, NotificationProfileTable.CREATED_AT to profile.createdAtMs,
NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls.toInt(), NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls.toInt(),
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.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() .run()

View File

@@ -338,6 +338,12 @@ private fun StorageRecordRow(record: SignalStorageRecord) {
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) 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 -> { else -> {
Column { Column {
Text("Unknown!") Text("Unknown!")

View File

@@ -76,6 +76,7 @@ class EditNotificationProfileScheduleViewModel(
repository.updateSchedule(schedule) repository.updateSchedule(schedule)
.toSingleDefault(SaveScheduleResult.Success) .toSingleDefault(SaveScheduleResult.Success)
.flatMap { r -> .flatMap { r ->
repository.scheduleNotificationProfileSync(profileId)
if (schedule.enabled && schedule.coversTime(System.currentTimeMillis())) { if (schedule.enabled && schedule.coversTime(System.currentTimeMillis())) {
repository.manuallyEnableProfileForSchedule(profileId, schedule) repository.manuallyEnableProfileForSchedule(profileId, schedule)
.toSingleDefault(r) .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.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.toLocalDateTime import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis import org.thoughtcrime.securesms.util.toMillis
@@ -56,36 +58,43 @@ class NotificationProfilesRepository {
fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> { fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> {
return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) } return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) }
.doOnSuccess { StorageSyncHelper.scheduleSyncForDataChange() }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> { fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) } return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun updateProfile(profile: NotificationProfile): Single<NotificationProfileTables.NotificationProfileChangeResult> { fun updateProfile(profile: NotificationProfile): Single<NotificationProfileTables.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profile) } return Single.fromCallable { database.updateProfile(profile) }
.doOnSuccess { scheduleNotificationProfileSync(profile.id) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> { fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> {
return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) } return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> { fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) } return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> { fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) } return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) }
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun deleteProfile(profileId: Long): Completable { fun deleteProfile(profileId: Long): Completable {
return Completable.fromCallable { database.deleteProfile(profileId) } return Completable.fromCallable { database.deleteProfile(profileId) }
.doOnComplete { scheduleNotificationProfileSync(profileId) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
@@ -132,7 +141,10 @@ class NotificationProfilesRepository {
SignalStore.notificationProfile.manuallyDisabledAt = now SignalStore.notificationProfile.manuallyDisabledAt = now
} }
} }
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() } .doOnComplete {
scheduleManualOverrideSync()
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
@@ -142,7 +154,10 @@ class NotificationProfilesRepository {
SignalStore.notificationProfile.manuallyEnabledUntil = enableUntil SignalStore.notificationProfile.manuallyEnabledUntil = enableUntil
SignalStore.notificationProfile.manuallyDisabledAt = now SignalStore.notificationProfile.manuallyDisabledAt = now
} }
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() } .doOnComplete {
scheduleManualOverrideSync()
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
.subscribeOn(Schedulers.io()) .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.manuallyEnabledUntil = if (inScheduledWindow) schedule.endDateTime(now.toLocalDateTime()).toMillis() else Long.MAX_VALUE
SignalStore.notificationProfile.manuallyDisabledAt = if (inScheduledWindow) now else 0 SignalStore.notificationProfile.manuallyDisabledAt = if (inScheduledWindow) now else 0
} }
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() } .doOnComplete {
scheduleManualOverrideSync()
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
}
.subscribeOn(Schedulers.io()) .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() 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.select
import org.signal.core.util.update import org.signal.core.util.update
import org.signal.core.util.withinTransaction 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.ChatFolderId
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.database.ThreadTable.Companion.ID import org.thoughtcrime.securesms.database.ThreadTable.Companion.ID
import org.thoughtcrime.securesms.dependencies.AppDependencies 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.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.push.ServiceId import org.thoughtcrime.securesms.storage.StorageSyncModels
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil 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") RemoteChatFolderRecord.FolderType.UNKNOWN -> throw AssertionError("Folder type cannot be unknown")
}, },
includedChats = record.proto.includedRecipients includedChats = record.proto.includedRecipients
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) } .mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }, .map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
excludedChats = record.proto.excludedRecipients excludedChats = record.proto.excludedRecipients
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) } .mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }, .map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
chatFolderId = chatFolderId, chatFolderId = chatFolderId,
storageServiceId = StorageId.forChatFolder(record.id.raw), 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> { private fun Collection<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
return map { return map {
contentValuesOf( contentValuesOf(

View File

@@ -5,23 +5,40 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor 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.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.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.requireBoolean
import org.signal.core.util.requireInt import org.signal.core.util.requireInt
import org.signal.core.util.requireLong import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toInt import org.signal.core.util.toInt
import org.signal.core.util.update import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.RecipientId 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 java.time.DayOfWeek
import kotlin.time.Duration.Companion.days
/** /**
* Database for maintaining Notification Profiles, Notification Profile Schedules, and Notification Profile allowed memebers. * 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 { companion object {
private val TAG = Log.tag(NotificationProfileTable::class) private val TAG = Log.tag(NotificationProfileTable::class)
private val DELETED_LIFESPAN: Long = 30.days.inWholeMilliseconds
@JvmField @JvmField
val CREATE_TABLE: Array<String> = arrayOf(NotificationProfileTable.CREATE_TABLE, NotificationProfileScheduleTable.CREATE_TABLE, NotificationProfileAllowedMembersTable.CREATE_TABLE) 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_CALLS = "allow_all_calls"
const val ALLOW_ALL_MENTIONS = "allow_all_mentions" const val ALLOW_ALL_MENTIONS = "allow_all_mentions"
const val NOTIFICATION_PROFILE_ID = "notification_profile_id" 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 = """ val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME ( CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT, $ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NAME TEXT NOT NULL UNIQUE, $NAME TEXT NOT NULL,
$EMOJI TEXT NOT NULL, $EMOJI TEXT NOT NULL,
$COLOR TEXT NOT NULL, $COLOR TEXT NOT NULL,
$CREATED_AT INTEGER NOT NULL, $CREATED_AT INTEGER NOT NULL,
$ALLOW_ALL_CALLS INTEGER NOT NULL DEFAULT 0, $ALLOW_ALL_CALLS INTEGER NOT NULL DEFAULT 0,
$ALLOW_ALL_MENTIONS 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() db.beginTransaction()
try { try {
if (isDuplicateName(name)) {
return NotificationProfileChangeResult.DuplicateName
}
val notificationProfileId = NotificationProfileId.generate() val notificationProfileId = NotificationProfileId.generate()
val storageServiceId = StorageSyncHelper.generateKey()
val profileValues = ContentValues().apply { val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, name) put(NotificationProfileTable.NAME, name)
put(NotificationProfileTable.EMOJI, emoji) put(NotificationProfileTable.EMOJI, emoji)
@@ -122,12 +151,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
put(NotificationProfileTable.CREATED_AT, createdAt) put(NotificationProfileTable.CREATED_AT, createdAt)
put(NotificationProfileTable.ALLOW_ALL_CALLS, 1) put(NotificationProfileTable.ALLOW_ALL_CALLS, 1)
put(NotificationProfileTable.NOTIFICATION_PROFILE_ID, notificationProfileId.serialize()) put(NotificationProfileTable.NOTIFICATION_PROFILE_ID, notificationProfileId.serialize())
put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId))
} }
val profileId = db.insert(NotificationProfileTable.TABLE_NAME, null, profileValues) val profileId = db.insert(NotificationProfileTable.TABLE_NAME, null, profileValues)
if (profileId < 0) {
return NotificationProfileChangeResult.DuplicateName
}
val scheduleValues = ContentValues().apply { val scheduleValues = ContentValues().apply {
put(NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID, profileId) put(NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID, profileId)
@@ -147,7 +174,8 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
createdAt = createdAt, createdAt = createdAt,
schedule = getProfileSchedule(profileId), schedule = getProfileSchedule(profileId),
allowAllCalls = true, allowAllCalls = true,
notificationProfileId = notificationProfileId notificationProfileId = notificationProfileId,
storageServiceId = StorageId.forNotificationProfile(storageServiceId)
) )
) )
} finally { } finally {
@@ -157,6 +185,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
} }
fun updateProfile(profileId: Long, name: String, emoji: String): NotificationProfileChangeResult { fun updateProfile(profileId: Long, name: String, emoji: String): NotificationProfileChangeResult {
if (isDuplicateName(name, profileId)) {
return NotificationProfileChangeResult.DuplicateName
}
val profileValues = ContentValues().apply { val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, name) put(NotificationProfileTable.NAME, name)
put(NotificationProfileTable.EMOJI, emoji) put(NotificationProfileTable.EMOJI, emoji)
@@ -164,37 +196,38 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profileId), profileValues) val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profileId), profileValues)
return try {
val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs) val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
if (count > 0) { if (count > 0) {
AppDependencies.databaseObserver.notifyNotificationProfileObservers() AppDependencies.databaseObserver.notifyNotificationProfileObservers()
} }
NotificationProfileChangeResult.Success(getProfile(profileId)!!) return NotificationProfileChangeResult.Success(getProfile(profileId)!!)
} catch (e: SQLiteConstraintException) {
NotificationProfileChangeResult.DuplicateName
}
} }
fun updateProfile(profile: NotificationProfile): NotificationProfileChangeResult { fun updateProfile(profile: NotificationProfile): NotificationProfileChangeResult {
if (isDuplicateName(profile.name, profile.id)) {
return NotificationProfileChangeResult.DuplicateName
}
val db = writableDatabase val db = writableDatabase
db.beginTransaction() db.beginTransaction()
try { try {
val storageServiceId = profile.storageServiceId?.raw ?: StorageSyncHelper.generateKey()
val storageServiceProto = if (profile.storageServiceProto != null) Base64.encodeWithPadding(profile.storageServiceProto) else null
val profileValues = ContentValues().apply { val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, profile.name) put(NotificationProfileTable.NAME, profile.name)
put(NotificationProfileTable.EMOJI, profile.emoji) put(NotificationProfileTable.EMOJI, profile.emoji)
put(NotificationProfileTable.ALLOW_ALL_CALLS, profile.allowAllCalls.toInt()) put(NotificationProfileTable.ALLOW_ALL_CALLS, profile.allowAllCalls.toInt())
put(NotificationProfileTable.ALLOW_ALL_MENTIONS, profile.allowAllMentions.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) val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profile.id), profileValues)
try {
db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs) db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
} catch (e: SQLiteConstraintException) {
return NotificationProfileChangeResult.DuplicateName
}
updateSchedule(profile.schedule, true) updateSchedule(profile.schedule, true)
@@ -280,16 +313,16 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
return getProfile(profileId)!! return getProfile(profileId)!!
} }
/**
* Returns all undeleted notification profiles
*/
fun getProfiles(): List<NotificationProfile> { fun getProfiles(): List<NotificationProfile> {
val profiles: MutableList<NotificationProfile> = mutableListOf() return readableDatabase
.select()
readableDatabase.query(NotificationProfileTable.TABLE_NAME, null, null, null, null, null, null).use { cursor -> .from(NotificationProfileTable.TABLE_NAME)
while (cursor.moveToNext()) { .where("${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0")
profiles += getProfile(cursor) .run()
} .readToList { cursor -> getProfile(cursor) }
}
return profiles
} }
fun getProfile(profileId: Long): NotificationProfile? { 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) { 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() 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) { override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
val count = writableDatabase val count = writableDatabase
.update(NotificationProfileAllowedMembersTable.TABLE_NAME) .update(NotificationProfileAllowedMembersTable.TABLE_NAME)
@@ -331,7 +549,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
allowAllMentions = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_MENTIONS), allowAllMentions = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_MENTIONS),
schedule = getProfileSchedule(profileId), schedule = getProfileSchedule(profileId),
allowedMembers = getProfileAllowedMembers(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 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 { sealed class NotificationProfileChangeResult {
data class Success(val notificationProfile: NotificationProfile) : NotificationProfileChangeResult() data class Success(val notificationProfile: NotificationProfile) : NotificationProfileChangeResult()
object DuplicateName : 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.V274_BackupMediaSnapshotLastSeenOnRemote
import org.thoughtcrime.securesms.database.helpers.migration.V275_EnsureDefaultAllChatsFolder 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.V276_AttachmentCdnDefaultValueMigration
import org.thoughtcrime.securesms.database.helpers.migration.V277_AddNotificationProfileStorageSync
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/** /**
@@ -267,10 +268,11 @@ object SignalDatabaseMigrations {
273 to V273_FixUnreadOriginalMessages, 273 to V273_FixUnreadOriginalMessages,
274 to V274_BackupMediaSnapshotLastSeenOnRemote, 274 to V274_BackupMediaSnapshotLastSeenOnRemote,
275 to V275_EnsureDefaultAllChatsFolder, 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 @JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { 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.signal.core.util.logging.logI
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.NotificationProfileTables
import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -110,6 +112,18 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
inserts.addAll(newChatFolderInserts) inserts.addAll(newChatFolderInserts)
allNewStorageIds.addAll(newChatFolderStorageIds.values) 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) { val recordIkm: RecordIkm? = if (Recipient.self().storageServiceEncryptionV2Capability.isSupported) {
Log.i(TAG, "Generating and including a new recordIkm.") Log.i(TAG, "Generating and including a new recordIkm.")
RecordIkm.generate() RecordIkm.generate()
@@ -151,6 +165,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds) SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds)
SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id)) SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id))
SignalDatabase.chatFolders.applyStorageIdUpdates(newChatFolderStorageIds) SignalDatabase.chatFolders.applyStorageIdUpdates(newChatFolderStorageIds)
SignalDatabase.notificationProfiles.applyStorageIdUpdates(newNotificationProfileStorageIds)
SignalDatabase.unknownStorageIds.deleteAll() SignalDatabase.unknownStorageIds.deleteAll()
} }
@@ -180,6 +195,12 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
return out 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?> { class Factory : Job.Factory<StorageForcePushJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): StorageForcePushJob { override fun create(parameters: Parameters, serializedData: ByteArray?): StorageForcePushJob {
return StorageForcePushJob(parameters) 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.core.util.withinTransaction
import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.protocol.InvalidKeyException
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.NotificationProfileTables
import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies 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.ContactRecordProcessor
import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor
import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor
import org.thoughtcrime.securesms.storage.NotificationProfileRecordProcessor
import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult
import org.thoughtcrime.securesms.storage.StorageSyncModels 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.SignalContactRecord
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record 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.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord 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.toSignalContactRecord
import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record
import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record 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.api.storage.toSignalStoryDistributionListRecord
import org.whispersystems.signalservice.internal.push.SyncMessage import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord 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()) { if (idDifference.localOnlyIds.isNotEmpty()) {
val updatedRecipients = SignalDatabase.recipients.removeStorageIdsFromLocalOnlyUnregisteredRecipients(idDifference.localOnlyIds) val updatedRecipients = SignalDatabase.recipients.removeStorageIdsFromLocalOnlyUnregisteredRecipients(idDifference.localOnlyIds)
val updatedFolders = SignalDatabase.chatFolders.removeStorageIdsFromLocalOnlyDeletedFolders(idDifference.localOnlyIds) val updatedFolders = SignalDatabase.chatFolders.removeStorageIdsFromLocalOnlyDeletedFolders(idDifference.localOnlyIds)
val updatedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromLocalOnlyDeletedProfiles(idDifference.localOnlyIds)
if (updatedRecipients > 0 || updatedFolders > 0) { if (updatedRecipients > 0 || updatedFolders > 0 || updatedProfiles > 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.") 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) localStorageIdsBeforeMerge = getAllLocalStorageIds(self)
idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge) idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge)
@@ -310,7 +315,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
db.beginTransaction() db.beginTransaction()
try { 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) processKnownRecords(context, remoteOnly)
@@ -352,13 +357,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
self = freshSelf() self = freshSelf()
val removedUnregistered = SignalDatabase.recipients.removeStorageIdsFromOldUnregisteredRecipients(System.currentTimeMillis()) 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()) val removedDeletedFolders = SignalDatabase.chatFolders.removeStorageIdsFromOldDeletedFolders(System.currentTimeMillis())
if (removedDeletedFolders > 0) { val removedDeletedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
Log.i(TAG, "Removed $removedDeletedFolders folders from storage service that have been deleted for longer than 30 days.") 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) val localStorageIds = getAllLocalStorageIds(self)
@@ -454,12 +456,14 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR) StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR)
CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR) CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR)
ChatFolderRecordProcessor().process(records.chatFolderRecords, StorageSyncHelper.KEY_GENERATOR) ChatFolderRecordProcessor().process(records.chatFolderRecords, StorageSyncHelper.KEY_GENERATOR)
NotificationProfileRecordProcessor().process(records.notificationProfileRecords, StorageSyncHelper.KEY_GENERATOR)
} }
private fun getAllLocalStorageIds(self: Recipient): List<StorageId> { private fun getAllLocalStorageIds(self: Recipient): List<StorageId> {
return SignalDatabase.recipients.getContactStorageSyncIds() + return SignalDatabase.recipients.getContactStorageSyncIds() +
listOf(StorageId.forAccount(self.storageId)) + listOf(StorageId.forAccount(self.storageId)) +
SignalDatabase.chatFolders.getStorageSyncIds() + SignalDatabase.chatFolders.getStorageSyncIds() +
SignalDatabase.notificationProfiles.getStorageSyncIds() +
SignalDatabase.unknownStorageIds.allUnknownIds 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 -> { else -> {
val unknown = SignalDatabase.unknownStorageIds.getById(id.raw) val unknown = SignalDatabase.unknownStorageIds.getById(id.raw)
if (unknown != null) { if (unknown != null) {
@@ -567,6 +581,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
val storyDistributionLists: MutableList<SignalStoryDistributionListRecord> = mutableListOf() val storyDistributionLists: MutableList<SignalStoryDistributionListRecord> = mutableListOf()
val callLinkRecords: MutableList<SignalCallLinkRecord> = mutableListOf() val callLinkRecords: MutableList<SignalCallLinkRecord> = mutableListOf()
val chatFolderRecords: MutableList<SignalChatFolderRecord> = mutableListOf() val chatFolderRecords: MutableList<SignalChatFolderRecord> = mutableListOf()
val notificationProfileRecords: MutableList<SignalNotificationProfileRecord> = mutableListOf()
init { init {
for (record in records) { for (record in records) {
@@ -584,6 +599,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id) callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id)
} else if (record.proto.chatFolder != null) { } else if (record.proto.chatFolder != null) {
chatFolderRecords += record.proto.chatFolder!!.toSignalChatFolderRecord(record.id) 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) { } else if (record.id.isUnknown) {
unknown += record unknown += record
} else { } else {
@@ -599,6 +616,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
private class MissingChatFolderModelError(message: String?) : Error(message) private class MissingChatFolderModelError(message: String?) : Error(message)
private class MissingNotificationProfileModelError(message: String?) : Error(message)
private class MissingUnknownModelError(message: String?) : Error(message) private class MissingUnknownModelError(message: String?) : Error(message)
class Factory : Job.Factory<StorageSyncJob?> { 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.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.storage.StorageId
data class NotificationProfile( data class NotificationProfile(
val id: Long, val id: Long,
val name: String, val name: String,
val emoji: String, val emoji: String,
val color: AvatarColor = AvatarColor.A210, val color: AvatarColor = DEFAULT_NOTIFICATION_PROFILE_COLOR,
val createdAt: Long, val createdAt: Long,
val allowAllCalls: Boolean = true, val allowAllCalls: Boolean = true,
val allowAllMentions: Boolean = false, val allowAllMentions: Boolean = false,
val schedule: NotificationProfileSchedule, val schedule: NotificationProfileSchedule,
val allowedMembers: Set<RecipientId> = emptySet(), 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> { ) : Comparable<NotificationProfile> {
companion object {
val DEFAULT_NOTIFICATION_PROFILE_COLOR = AvatarColor.A210
}
fun isRecipientAllowed(id: RecipientId): Boolean { fun isRecipientAllowed(id: RecipientId): Boolean {
return allowedMembers.contains(id) return allowedMembers.contains(id)
} }

View File

@@ -5,7 +5,6 @@ import okio.ByteString
import org.signal.core.util.isNotEmpty import org.signal.core.util.isNotEmpty
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfEmpty import org.signal.core.util.nullIfEmpty
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
@@ -125,7 +124,6 @@ class AccountRecordProcessor(
preferContactAvatars = remote.proto.preferContactAvatars preferContactAvatars = remote.proto.preferContactAvatars
universalExpireTimer = remote.proto.universalExpireTimer universalExpireTimer = remote.proto.universalExpireTimer
primarySendsSms = false primarySendsSms = false
e164 = if (SignalStore.account.isPrimaryDevice) local.proto.e164 else remote.proto.e164
preferredReactionEmoji = remote.proto.preferredReactionEmoji.takeIf { it.isNotEmpty() } ?: local.proto.preferredReactionEmoji preferredReactionEmoji = remote.proto.preferredReactionEmoji.takeIf { it.isNotEmpty() } ?: local.proto.preferredReactionEmoji
displayBadgesOnProfile = remote.proto.displayBadgesOnProfile displayBadgesOnProfile = remote.proto.displayBadgesOnProfile
subscriptionManuallyCancelled = remote.proto.subscriptionManuallyCancelled subscriptionManuallyCancelled = remote.proto.subscriptionManuallyCancelled
@@ -138,6 +136,7 @@ class AccountRecordProcessor(
hasCompletedUsernameOnboarding = remote.proto.hasCompletedUsernameOnboarding || local.proto.hasCompletedUsernameOnboarding hasCompletedUsernameOnboarding = remote.proto.hasCompletedUsernameOnboarding || local.proto.hasCompletedUsernameOnboarding
username = remote.proto.username username = remote.proto.username
usernameLink = remote.proto.usernameLink usernameLink = remote.proto.usernameLink
notificationProfileManualOverride = remote.proto.notificationProfileManualOverride
safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray()) safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray())
safeSetSubscriber(donationSubscriberId, donationSubscriberCurrencyCode) 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.OptionalUtil.asOptional
import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord
import org.whispersystems.signalservice.internal.storage.protos.Recipient
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -96,7 +97,7 @@ class ChatFolderRecordProcessor : DefaultStorageRecordProcessor<SignalChatFolder
SignalDatabase.chatFolders.updateChatFolderFromStorageSync(update.new) SignalDatabase.chatFolders.updateChatFolderFromStorageSync(update.new)
} }
private fun containsInvalidServiceId(recipients: List<ChatFolderRecord.Recipient>): Boolean { private fun containsInvalidServiceId(recipients: List<Recipient>): Boolean {
return recipients.any { recipient -> return recipients.any { recipient ->
recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null 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
import okio.ByteString.Companion.toByteString import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64.encodeWithPadding import org.signal.core.util.Base64.encodeWithPadding
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log 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.getSubscriber
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.isUserManuallyCancelled import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.isUserManuallyCancelled
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.setSubscriber 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.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord 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.AccountValues
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.payments.Entropy import org.thoughtcrime.securesms.payments.Entropy
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.Recipient.Companion.self import org.thoughtcrime.securesms.recipients.Recipient.Companion.self
@@ -171,6 +174,7 @@ object StorageSyncHelper {
color = StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc.usernameQrCodeColorScheme) color = StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc.usernameQrCodeColorScheme)
) )
} }
notificationProfileManualOverride = getNotificationProfileManualOverride()
getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)?.let { getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)?.let {
safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency?.currencyCode ?: "") safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency?.currencyCode ?: "")
@@ -186,6 +190,24 @@ object StorageSyncHelper {
return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord() 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 @JvmStatic
fun applyAccountStorageSyncUpdates(context: Context, self: Recipient, updatedRecord: SignalAccountRecord, fetchProfile: Boolean) { fun applyAccountStorageSyncUpdates(context: Context, self: Recipient, updatedRecord: SignalAccountRecord, fetchProfile: Boolean) {
val localRecord = buildAccountRecord(context, self).let { it.proto.account!!.toSignalAccountRecord(it.id) } 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) 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 @JvmStatic

View File

@@ -4,6 +4,8 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString import okio.ByteString.Companion.toByteString
import org.signal.core.util.isNotEmpty import org.signal.core.util.isNotEmpty
import org.signal.core.util.isNullOrEmpty 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.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme 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.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData 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.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient 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.IAPSubscriptionId
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record 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.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
import org.whispersystems.signalservice.api.storage.StorageId 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.toSignalContactRecord
import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record
import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record 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.toSignalStorageRecord
import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord
import org.whispersystems.signalservice.api.subscriptions.SubscriberId 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
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record
import java.time.DayOfWeek
import java.util.Currency import java.util.Currency
import kotlin.math.max import kotlin.math.max
import org.whispersystems.signalservice.internal.storage.protos.AvatarColor as RemoteAvatarColor 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.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 { object StorageSyncModels {
private val TAG = Log.tag(StorageSyncModels::class.java)
fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord { fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord {
if (settings.storageId == null) { if (settings.storageId == null) {
throw AssertionError("Must have a storage key!") throw AssertionError("Must have a storage key!")
@@ -82,6 +97,10 @@ object StorageSyncModels {
return localToRemoteChatFolder(folder, rawStorageId).toSignalStorageRecord() return localToRemoteChatFolder(folder, rawStorageId).toSignalStorageRecord()
} }
fun localToRemoteRecord(profile: NotificationProfile, rawStorageId: ByteArray): SignalStorageRecord {
return localToRemoteNotificationProfile(profile, rawStorageId).toSignalStorageRecord()
}
@JvmStatic @JvmStatic
fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode { fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode {
return when (phoneNumberPhoneNumberSharingMode) { return when (phoneNumberPhoneNumberSharingMode) {
@@ -396,22 +415,96 @@ object StorageSyncModels {
}.build().toSignalChatFolderRecord(StorageId.forChatFolder(rawStorageId)) }.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) val recipientIds = SignalDatabase.threads.getRecipientIdsForThreadIds(threadIds)
return localToRemoteRecipients(recipientIds)
}
private fun localToRemoteRecipients(recipientIds: List<RecipientId>): List<RemoteRecipient> {
return recipientIds.mapNotNull { id -> return recipientIds.mapNotNull { id ->
val recipient = SignalDatabase.recipients.getRecordForSync(id) ?: throw AssertionError("Missing recipient for id") val recipient = SignalDatabase.recipients.getRecordForSync(id) ?: throw AssertionError("Missing recipient for id")
when (recipient.recipientType) { when (recipient.recipientType) {
RecipientType.INDIVIDUAL -> { 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 -> { RecipientType.GV1 -> {
RemoteChatFolder.Recipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString()) RemoteRecipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString())
} }
RecipientType.GV2 -> { RecipientType.GV2 -> {
RemoteChatFolder.Recipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString()) RemoteRecipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString())
} }
else -> null 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(); 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(); throw new DuplicateRawIdAcrossTypesError();
} }
@@ -217,6 +222,9 @@ public final class StorageSyncValidations {
private static final class DuplicateInsertInWriteError extends Error { private static final class DuplicateInsertInWriteError extends Error {
} }
private static final class DuplicateNotificationProfileError extends Error {
}
private static final class InsertNotPresentInFullIdSetError extends Error { private static final class InsertNotPresentInFullIdSetError extends Error {
} }

View File

@@ -52,6 +52,9 @@ class StorageServicePlugin : Plugin {
} else if (record.proto.chatFolder != null) { } else if (record.proto.chatFolder != null) {
row += "Chat Folder" row += "Chat Folder"
row += record.proto.chatFolder.toString() row += record.proto.chatFolder.toString()
} else if (record.proto.notificationProfile != null) {
row += "Notification Profile"
row += record.proto.notificationProfile.toString()
} else { } else {
row += "Unknown" row += "Unknown"
row += "" 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.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord
import org.whispersystems.signalservice.internal.storage.protos.Recipient
import java.util.UUID import java.util.UUID
/** /**
@@ -203,7 +204,7 @@ class ChatFolderRecordProcessorTest {
includeAllGroupChats = false includeAllGroupChats = false
folderType = ChatFolderRecord.FolderType.CUSTOM folderType = ChatFolderRecord.FolderType.CUSTOM
deletedAtTimestampMs = 0L deletedAtTimestampMs = 0L
includedRecipients = listOf(ChatFolderRecord.Recipient(contact = ChatFolderRecord.Recipient.Contact(serviceId = "bad"))) includedRecipients = listOf(Recipient(contact = Recipient.Contact(serviceId = "bad")))
}.build() }.build()
val record = SignalChatFolderRecord(STORAGE_ID, proto) 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 proto: StorageRecord
) { ) {
val isUnknown: Boolean 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 { companion object {
@JvmStatic @JvmStatic

View File

@@ -42,6 +42,10 @@ public class StorageId {
return new StorageId(ManifestRecord.Identifier.Type.CHAT_FOLDER.getValue(), Preconditions.checkNotNull(raw)); 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) { public static StorageId forType(byte[] raw, int type) {
return new StorageId(type, raw); 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.ContactRecord
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record 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.StorageRecord
import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord
@@ -46,6 +47,10 @@ fun ChatFolderRecord.toSignalChatFolderRecord(storageId: StorageId): SignalChatF
return SignalChatFolderRecord(storageId, this) return SignalChatFolderRecord(storageId, this)
} }
fun NotificationProfile.toSignalNotificationProfileRecord(storageId: StorageId): SignalNotificationProfileRecord {
return SignalNotificationProfileRecord(storageId, this)
}
fun SignalContactRecord.toSignalStorageRecord(): SignalStorageRecord { fun SignalContactRecord.toSignalStorageRecord(): SignalStorageRecord {
return SignalStorageRecord(id, StorageRecord(contact = this.proto)) return SignalStorageRecord(id, StorageRecord(contact = this.proto))
} }
@@ -73,3 +78,7 @@ fun SignalCallLinkRecord.toSignalStorageRecord(): SignalStorageRecord {
fun SignalChatFolderRecord.toSignalStorageRecord(): SignalStorageRecord { fun SignalChatFolderRecord.toSignalStorageRecord(): SignalStorageRecord {
return SignalStorageRecord(id, StorageRecord(chatFolder = this.proto)) 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; STORY_DISTRIBUTION_LIST = 5;
CALL_LINK = 7; CALL_LINK = 7;
CHAT_FOLDER = 8; CHAT_FOLDER = 8;
NOTIFICATION_PROFILE = 9;
} }
bytes raw = 1; bytes raw = 1;
@@ -74,6 +75,7 @@ message StorageRecord {
StoryDistributionListRecord storyDistributionList = 5; StoryDistributionListRecord storyDistributionList = 5;
CallLinkRecord callLink = 7; CallLinkRecord callLink = 7;
ChatFolderRecord chatFolder = 8; 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; bytes profileKey = 1;
string givenName = 2; string givenName = 2;
string familyName = 3; string familyName = 3;
@@ -243,7 +265,7 @@ message AccountRecord {
Payments payments = 16; Payments payments = 16;
uint32 universalExpireTimer = 17; uint32 universalExpireTimer = 17;
bool primarySendsSms = 18; bool primarySendsSms = 18;
string e164 = 19; reserved /* e164 */ 19;
repeated string preferredReactionEmoji = 20; repeated string preferredReactionEmoji = 20;
bytes subscriberId = 21; bytes subscriberId = 21;
string subscriberCurrencyCode = 22; string subscriberCurrencyCode = 22;
@@ -264,9 +286,11 @@ message AccountRecord {
reserved /* backupsSubscriberCurrencyCode */ 37; reserved /* backupsSubscriberCurrencyCode */ 37;
reserved /* backupsSubscriptionManuallyCancelled */ 38; reserved /* backupsSubscriptionManuallyCancelled */ 38;
optional bool hasBackup = 39; // Set to true after backups are enabled and one is uploaded. 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; IAPSubscriberData backupSubscriberData = 41;
optional AvatarColor avatarColor = 42; optional AvatarColor avatarColor = 42;
BackupTierHistory backupTierHistory = 43;
NotificationProfileManualOverride notificationProfileManualOverride = 44;
} }
message StoryDistributionListRecord { message StoryDistributionListRecord {
@@ -284,7 +308,6 @@ message CallLinkRecord {
uint64 deletedAtTimestampMs = 3; uint64 deletedAtTimestampMs = 3;
} }
message ChatFolderRecord {
message Recipient { message Recipient {
message Contact { message Contact {
string serviceId = 1; string serviceId = 1;
@@ -298,6 +321,7 @@ message ChatFolderRecord {
} }
} }
message ChatFolderRecord {
// Represents the default "All chats" folder record vs all other custom folders // Represents the default "All chats" folder record vs all other custom folders
enum FolderType { enum FolderType {
UNKNOWN = 0; UNKNOWN = 0;
@@ -317,3 +341,30 @@ message ChatFolderRecord {
repeated Recipient excludedRecipients = 10; repeated Recipient excludedRecipients = 10;
uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and includedRecipients should be empty 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;
}