From e3ee3d3dbad2c3d513cb459b9843fb284e207447 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Wed, 4 Jun 2025 11:02:43 -0400 Subject: [PATCH] Add notification profiles to storage service. --- .../database/ChatFolderTablesTest.kt | 7 +- .../database/NotificationProfileTablesTest.kt | 174 ++++++++++ .../processor/NotificationProfileProcessor.kt | 5 +- ...nternalStorageServicePlaygroundFragment.kt | 6 + ...ditNotificationProfileScheduleViewModel.kt | 1 + .../NotificationProfilesRepository.kt | 40 ++- .../securesms/database/ChatFolderTables.kt | 40 +-- .../database/NotificationProfileTables.kt | 303 ++++++++++++++++-- .../helpers/SignalDatabaseMigrations.kt | 6 +- .../V277_AddNotificationProfileStorageSync.kt | 50 +++ .../securesms/jobs/StorageForcePushJob.kt | 21 ++ .../securesms/jobs/StorageSyncJob.kt | 37 ++- .../profiles/NotificationProfile.kt | 12 +- .../storage/AccountRecordProcessor.kt | 3 +- .../storage/ChatFolderRecordProcessor.kt | 3 +- .../NotificationProfileRecordProcessor.kt | 95 ++++++ .../securesms/storage/StorageSyncHelper.kt | 49 +++ .../securesms/storage/StorageSyncModels.kt | 101 +++++- .../storage/StorageSyncValidations.java | 8 + .../securesms/StorageServicePlugin.kt | 3 + .../storage/ChatFolderRecordProcessorTest.kt | 3 +- .../NotificationProfileRecordProcessorTest.kt | 116 +++++++ .../SignalNotificationProfileRecord.kt | 27 ++ .../api/storage/SignalStorageRecord.kt | 2 +- .../signalservice/api/storage/StorageId.java | 4 + .../api/storage/StorageRecordConverters.kt | 9 + .../src/main/protowire/StorageService.proto | 159 +++++---- 27 files changed, 1132 insertions(+), 152 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V277_AddNotificationProfileStorageSync.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessor.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessorTest.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalNotificationProfileRecord.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt index 993ce2fb25..7969c442a2 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt @@ -26,6 +26,7 @@ import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.util.UuidUtil import java.util.UUID import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord +import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient @RunWith(AndroidJUnit4::class) class ChatFolderTablesTest { @@ -168,11 +169,11 @@ class ChatFolderTablesTest { folderType = RemoteChatFolderRecord.FolderType.CUSTOM, deletedAtTimestampMs = folder1.deletedTimestampMs, includedRecipients = listOf( - RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(alice).serviceId.get().toString())), - RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(bob).serviceId.get().toString())) + RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString())), + RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(bob).serviceId.get().toString())) ), excludedRecipients = listOf( - RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(charlie).serviceId.get().toString())) + RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(charlie).serviceId.get().toString())) ) ) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt new file mode 100644 index 0000000000..cb5d53682c --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/NotificationProfileProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/NotificationProfileProcessor.kt index dc7dcb073c..3917029a06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/NotificationProfileProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/NotificationProfileProcessor.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.backup.v2.processor import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64 import org.signal.core.util.insertInto import org.signal.core.util.logging.Log import org.signal.core.util.toInt @@ -22,6 +23,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.serialize import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.whispersystems.signalservice.api.util.UuidUtil import java.time.DayOfWeek import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as NotificationProfileProto @@ -59,7 +61,8 @@ object NotificationProfileProcessor { NotificationProfileTable.CREATED_AT to profile.createdAtMs, NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls.toInt(), NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.toInt(), - NotificationProfileTable.NOTIFICATION_PROFILE_ID to notificationProfileUuid.toString() + NotificationProfileTable.NOTIFICATION_PROFILE_ID to notificationProfileUuid.toString(), + NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey()) ) .run() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt index de518c8ad6..c88730db52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt @@ -338,6 +338,12 @@ private fun StorageRecordRow(record: SignalStorageRecord) { ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) } } + record.proto.notificationProfile != null -> { + Column { + Text("Notification Profile", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } else -> { Column { Text("Unknown!") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/EditNotificationProfileScheduleViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/EditNotificationProfileScheduleViewModel.kt index f947a08647..8f2f75eca4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/EditNotificationProfileScheduleViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/EditNotificationProfileScheduleViewModel.kt @@ -76,6 +76,7 @@ class EditNotificationProfileScheduleViewModel( repository.updateSchedule(schedule) .toSingleDefault(SaveScheduleResult.Success) .flatMap { r -> + repository.scheduleNotificationProfileSync(profileId) if (schedule.enabled && schedule.coversTime(System.currentTimeMillis())) { repository.manuallyEnableProfileForSchedule(profileId, schedule) .toSingleDefault(r) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesRepository.kt index aac2e1d69e..d14333d43d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesRepository.kt @@ -16,7 +16,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.toLocalDateTime import org.thoughtcrime.securesms.util.toMillis @@ -56,36 +58,43 @@ class NotificationProfilesRepository { fun createProfile(name: String, selectedEmoji: String): Single { return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) } + .doOnSuccess { StorageSyncHelper.scheduleSyncForDataChange() } .subscribeOn(Schedulers.io()) } fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single { return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) } + .doOnSuccess { scheduleNotificationProfileSync(profileId) } .subscribeOn(Schedulers.io()) } fun updateProfile(profile: NotificationProfile): Single { return Single.fromCallable { database.updateProfile(profile) } + .doOnSuccess { scheduleNotificationProfileSync(profile.id) } .subscribeOn(Schedulers.io()) } fun updateAllowedMembers(profileId: Long, recipients: Set): Single { return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) } + .doOnSuccess { scheduleNotificationProfileSync(profileId) } .subscribeOn(Schedulers.io()) } fun removeMember(profileId: Long, recipientId: RecipientId): Single { return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) } + .doOnSuccess { scheduleNotificationProfileSync(profileId) } .subscribeOn(Schedulers.io()) } fun addMember(profileId: Long, recipientId: RecipientId): Single { return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) } + .doOnSuccess { scheduleNotificationProfileSync(profileId) } .subscribeOn(Schedulers.io()) } fun deleteProfile(profileId: Long): Completable { return Completable.fromCallable { database.deleteProfile(profileId) } + .doOnComplete { scheduleNotificationProfileSync(profileId) } .subscribeOn(Schedulers.io()) } @@ -132,7 +141,10 @@ class NotificationProfilesRepository { SignalStore.notificationProfile.manuallyDisabledAt = now } } - .doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() } + .doOnComplete { + scheduleManualOverrideSync() + AppDependencies.databaseObserver.notifyNotificationProfileObservers() + } .subscribeOn(Schedulers.io()) } @@ -142,7 +154,10 @@ class NotificationProfilesRepository { SignalStore.notificationProfile.manuallyEnabledUntil = enableUntil SignalStore.notificationProfile.manuallyDisabledAt = now } - .doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() } + .doOnComplete { + scheduleManualOverrideSync() + AppDependencies.databaseObserver.notifyNotificationProfileObservers() + } .subscribeOn(Schedulers.io()) } @@ -153,9 +168,28 @@ class NotificationProfilesRepository { SignalStore.notificationProfile.manuallyEnabledUntil = if (inScheduledWindow) schedule.endDateTime(now.toLocalDateTime()).toMillis() else Long.MAX_VALUE SignalStore.notificationProfile.manuallyDisabledAt = if (inScheduledWindow) now else 0 } - .doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() } + .doOnComplete { + scheduleManualOverrideSync() + AppDependencies.databaseObserver.notifyNotificationProfileObservers() + } .subscribeOn(Schedulers.io()) } + /** + * Schedules a sync for a notification profile when it changes + */ + fun scheduleNotificationProfileSync(profileId: Long) { + SignalDatabase.notificationProfiles.markNeedsSync(profileId) + StorageSyncHelper.scheduleSyncForDataChange() + } + + /** + * Schedules a sync for the self when the manual notification profile changes + */ + private fun scheduleManualOverrideSync() { + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } + class NotificationProfileNotFoundException : Throwable() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt index af83f53a72..a8fd2e1092 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt @@ -24,18 +24,12 @@ import org.signal.core.util.requireString import org.signal.core.util.select import org.signal.core.util.update import org.signal.core.util.withinTransaction -import org.signal.libsignal.zkgroup.InvalidInputException -import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord import org.thoughtcrime.securesms.database.ThreadTable.Companion.ID import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.groups.BadGroupIdException -import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.storage.StorageSyncHelper -import org.whispersystems.signalservice.api.push.ServiceId -import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.thoughtcrime.securesms.storage.StorageSyncModels import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.util.UuidUtil @@ -684,10 +678,10 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat RemoteChatFolderRecord.FolderType.UNKNOWN -> throw AssertionError("Folder type cannot be unknown") }, includedChats = record.proto.includedRecipients - .mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) } + .mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) } .map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }, excludedChats = record.proto.excludedRecipients - .mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) } + .mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) } .map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }, chatFolderId = chatFolderId, storageServiceId = StorageId.forChatFolder(record.id.raw), @@ -696,34 +690,6 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat ) } - /** - * Parses a remote recipient into a local one. Used when configuring the chats of a remote chat folder into a local one. - */ - private fun getRecipientIdFromRemoteRecipient(remoteRecipient: RemoteChatFolderRecord.Recipient): Recipient? { - return if (remoteRecipient.contact != null) { - val serviceId = ServiceId.parseOrNull(remoteRecipient.contact!!.serviceId) - val e164 = remoteRecipient.contact!!.e164 - Recipient.externalPush(SignalServiceAddress(serviceId, e164)) - } else if (remoteRecipient.legacyGroupId != null) { - try { - Recipient.externalGroupExact(GroupId.v1(remoteRecipient.legacyGroupId!!.toByteArray())) - } catch (e: BadGroupIdException) { - Log.w(TAG, "Failed to parse groupV1 ID!", e) - null - } - } else if (remoteRecipient.groupMasterKey != null) { - try { - Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(remoteRecipient.groupMasterKey!!.toByteArray()))) - } catch (e: InvalidInputException) { - Log.w(TAG, "Failed to parse groupV2 master key!", e) - null - } - } else { - Log.w(TAG, "Could not find recipient") - null - } - } - private fun Collection.toContentValues(chatFolderId: Long, membershipType: MembershipType): List { return map { contentValuesOf( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt index 885a4f58ce..8f8a06af62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt @@ -5,23 +5,40 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor -import android.database.sqlite.SQLiteConstraintException +import androidx.core.content.contentValuesOf +import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil +import org.signal.core.util.exists +import org.signal.core.util.hasUnknownFields +import org.signal.core.util.insertInto import org.signal.core.util.logging.Log +import org.signal.core.util.readToList +import org.signal.core.util.readToMap +import org.signal.core.util.readToSingleLong +import org.signal.core.util.readToSingleObject import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString +import org.signal.core.util.select import org.signal.core.util.toInt import org.signal.core.util.update +import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncHelper +import org.thoughtcrime.securesms.storage.StorageSyncModels +import org.thoughtcrime.securesms.storage.StorageSyncModels.toLocal +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.util.UuidUtil import java.time.DayOfWeek +import kotlin.time.Duration.Companion.days /** * Database for maintaining Notification Profiles, Notification Profile Schedules, and Notification Profile allowed memebers. @@ -30,6 +47,7 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase companion object { private val TAG = Log.tag(NotificationProfileTable::class) + private val DELETED_LIFESPAN: Long = 30.days.inWholeMilliseconds @JvmField val CREATE_TABLE: Array = arrayOf(NotificationProfileTable.CREATE_TABLE, NotificationProfileScheduleTable.CREATE_TABLE, NotificationProfileAllowedMembersTable.CREATE_TABLE) @@ -49,17 +67,23 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase const val ALLOW_ALL_CALLS = "allow_all_calls" const val ALLOW_ALL_MENTIONS = "allow_all_mentions" const val NOTIFICATION_PROFILE_ID = "notification_profile_id" + const val DELETED_TIMESTAMP_MS = "deleted_timestamp_ms" + const val STORAGE_SERVICE_ID = "storage_service_id" + const val STORAGE_SERVICE_PROTO = "storage_service_proto" val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( $ID INTEGER PRIMARY KEY AUTOINCREMENT, - $NAME TEXT NOT NULL UNIQUE, + $NAME TEXT NOT NULL, $EMOJI TEXT NOT NULL, $COLOR TEXT NOT NULL, $CREATED_AT INTEGER NOT NULL, $ALLOW_ALL_CALLS INTEGER NOT NULL DEFAULT 0, $ALLOW_ALL_MENTIONS INTEGER NOT NULL DEFAULT 0, - $NOTIFICATION_PROFILE_ID TEXT DEFAULT NULL + $NOTIFICATION_PROFILE_ID TEXT DEFAULT NULL, + $DELETED_TIMESTAMP_MS INTEGER DEFAULT 0, + $STORAGE_SERVICE_ID TEXT DEFAULT NULL, + $STORAGE_SERVICE_PROTO TEXT DEFAULT NULL ) """ } @@ -114,7 +138,12 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase db.beginTransaction() try { + if (isDuplicateName(name)) { + return NotificationProfileChangeResult.DuplicateName + } + val notificationProfileId = NotificationProfileId.generate() + val storageServiceId = StorageSyncHelper.generateKey() val profileValues = ContentValues().apply { put(NotificationProfileTable.NAME, name) put(NotificationProfileTable.EMOJI, emoji) @@ -122,12 +151,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase put(NotificationProfileTable.CREATED_AT, createdAt) put(NotificationProfileTable.ALLOW_ALL_CALLS, 1) put(NotificationProfileTable.NOTIFICATION_PROFILE_ID, notificationProfileId.serialize()) + put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId)) } val profileId = db.insert(NotificationProfileTable.TABLE_NAME, null, profileValues) - if (profileId < 0) { - return NotificationProfileChangeResult.DuplicateName - } val scheduleValues = ContentValues().apply { put(NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID, profileId) @@ -147,7 +174,8 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase createdAt = createdAt, schedule = getProfileSchedule(profileId), allowAllCalls = true, - notificationProfileId = notificationProfileId + notificationProfileId = notificationProfileId, + storageServiceId = StorageId.forNotificationProfile(storageServiceId) ) ) } finally { @@ -157,6 +185,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase } fun updateProfile(profileId: Long, name: String, emoji: String): NotificationProfileChangeResult { + if (isDuplicateName(name, profileId)) { + return NotificationProfileChangeResult.DuplicateName + } + val profileValues = ContentValues().apply { put(NotificationProfileTable.NAME, name) put(NotificationProfileTable.EMOJI, emoji) @@ -164,37 +196,38 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profileId), profileValues) - return try { - val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs) - if (count > 0) { - AppDependencies.databaseObserver.notifyNotificationProfileObservers() - } - - NotificationProfileChangeResult.Success(getProfile(profileId)!!) - } catch (e: SQLiteConstraintException) { - NotificationProfileChangeResult.DuplicateName + val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs) + if (count > 0) { + AppDependencies.databaseObserver.notifyNotificationProfileObservers() } + + return NotificationProfileChangeResult.Success(getProfile(profileId)!!) } fun updateProfile(profile: NotificationProfile): NotificationProfileChangeResult { + if (isDuplicateName(profile.name, profile.id)) { + return NotificationProfileChangeResult.DuplicateName + } + val db = writableDatabase db.beginTransaction() try { + val storageServiceId = profile.storageServiceId?.raw ?: StorageSyncHelper.generateKey() + val storageServiceProto = if (profile.storageServiceProto != null) Base64.encodeWithPadding(profile.storageServiceProto) else null + val profileValues = ContentValues().apply { put(NotificationProfileTable.NAME, profile.name) put(NotificationProfileTable.EMOJI, profile.emoji) put(NotificationProfileTable.ALLOW_ALL_CALLS, profile.allowAllCalls.toInt()) put(NotificationProfileTable.ALLOW_ALL_MENTIONS, profile.allowAllMentions.toInt()) + put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId)) + put(NotificationProfileTable.STORAGE_SERVICE_PROTO, storageServiceProto) } val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profile.id), profileValues) - try { - db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs) - } catch (e: SQLiteConstraintException) { - return NotificationProfileChangeResult.DuplicateName - } + db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs) updateSchedule(profile.schedule, true) @@ -280,16 +313,16 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase return getProfile(profileId)!! } + /** + * Returns all undeleted notification profiles + */ fun getProfiles(): List { - val profiles: MutableList = mutableListOf() - - readableDatabase.query(NotificationProfileTable.TABLE_NAME, null, null, null, null, null, null).use { cursor -> - while (cursor.moveToNext()) { - profiles += getProfile(cursor) - } - } - - return profiles + return readableDatabase + .select() + .from(NotificationProfileTable.TABLE_NAME) + .where("${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0") + .run() + .readToList { cursor -> getProfile(cursor) } } fun getProfile(profileId: Long): NotificationProfile? { @@ -302,11 +335,196 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase } } + fun getProfile(query: SqlUtil.Query): NotificationProfile? { + return readableDatabase + .select() + .from(NotificationProfileTable.TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .readToSingleObject { cursor -> getProfile(cursor) } + } + fun deleteProfile(profileId: Long) { - writableDatabase.delete(NotificationProfileTable.TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(profileId)) + writableDatabase.withinTransaction { db -> + db.update(NotificationProfileTable.TABLE_NAME) + .values(NotificationProfileTable.DELETED_TIMESTAMP_MS to System.currentTimeMillis()) + .where("${NotificationProfileTable.ID} = ?", profileId) + .run() + } + AppDependencies.databaseObserver.notifyNotificationProfileObservers() } + fun markNeedsSync(profileId: Long) { + writableDatabase.withinTransaction { + rotateStorageId(profileId) + } + } + + fun applyStorageIdUpdate(id: NotificationProfileId, storageId: StorageId) { + applyStorageIdUpdates(hashMapOf(id to storageId)) + } + + fun applyStorageIdUpdates(storageIds: Map) { + 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 { + 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 { + 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): Int { + var updated = 0 + + SqlUtil.buildCollectionQuery(NotificationProfileTable.STORAGE_SERVICE_ID, storageIds.map { Base64.encodeWithPadding(it.raw) }, "${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND") + .forEach { + updated += writableDatabase.update( + NotificationProfileTable.TABLE_NAME, + contentValuesOf(NotificationProfileTable.STORAGE_SERVICE_ID to null), + it.where, + it.whereArgs + ) + } + + return updated + } + override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { val count = writableDatabase .update(NotificationProfileAllowedMembersTable.TABLE_NAME) @@ -331,7 +549,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase allowAllMentions = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_MENTIONS), schedule = getProfileSchedule(profileId), allowedMembers = getProfileAllowedMembers(profileId), - notificationProfileId = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID)) + notificationProfileId = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID)), + deletedTimestampMs = cursor.requireLong(NotificationProfileTable.DELETED_TIMESTAMP_MS), + storageServiceId = cursor.requireString(NotificationProfileTable.STORAGE_SERVICE_ID)?.let { StorageId.forNotificationProfile(Base64.decodeNullableOrThrow(it)) }, + storageServiceProto = Base64.decodeOrNull(cursor.requireString(NotificationProfileTable.STORAGE_SERVICE_PROTO)) ) } @@ -372,6 +593,24 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase return allowed } + private fun rotateStorageId(id: Long) { + writableDatabase + .update(NotificationProfileTable.TABLE_NAME) + .values(NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey())) + .where("${NotificationProfileTable.ID} = ?", id) + .run() + } + + /** + * Checks that there is no other notification profile with the same [name] + */ + private fun isDuplicateName(name: String, id: Long = -1): Boolean { + return readableDatabase + .exists(NotificationProfileTable.TABLE_NAME) + .where("${NotificationProfileTable.NAME} = ? AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0 AND ${NotificationProfileTable.ID} != ?", name, id) + .run() + } + sealed class NotificationProfileChangeResult { data class Success(val notificationProfile: NotificationProfile) : NotificationProfileChangeResult() object DuplicateName : NotificationProfileChangeResult() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 5951501449..349619db09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -131,6 +131,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V273_FixUnreadOrigi import org.thoughtcrime.securesms.database.helpers.migration.V274_BackupMediaSnapshotLastSeenOnRemote import org.thoughtcrime.securesms.database.helpers.migration.V275_EnsureDefaultAllChatsFolder import org.thoughtcrime.securesms.database.helpers.migration.V276_AttachmentCdnDefaultValueMigration +import org.thoughtcrime.securesms.database.helpers.migration.V277_AddNotificationProfileStorageSync import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -267,10 +268,11 @@ object SignalDatabaseMigrations { 273 to V273_FixUnreadOriginalMessages, 274 to V274_BackupMediaSnapshotLastSeenOnRemote, 275 to V275_EnsureDefaultAllChatsFolder, - 276 to V276_AttachmentCdnDefaultValueMigration + 276 to V276_AttachmentCdnDefaultValueMigration, + 277 to V277_AddNotificationProfileStorageSync ) - const val DATABASE_VERSION = 276 + const val DATABASE_VERSION = 277 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V277_AddNotificationProfileStorageSync.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V277_AddNotificationProfileStorageSync.kt new file mode 100644 index 0000000000..e040e71bd9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V277_AddNotificationProfileStorageSync.kt @@ -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") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt index b60715901b..1eb2f88a98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt @@ -5,12 +5,14 @@ import org.signal.core.util.logging.Log import org.signal.core.util.logging.logI import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable +import org.thoughtcrime.securesms.database.NotificationProfileTables import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.storage.StorageSyncHelper @@ -110,6 +112,18 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob( inserts.addAll(newChatFolderInserts) allNewStorageIds.addAll(newChatFolderStorageIds.values) + val oldNotificationProfileStorageIds = SignalDatabase.notificationProfiles.getStorageSyncIdsMap() + val newNotificationProfileStorageIds = generateNotificationProfileStorageIds(oldNotificationProfileStorageIds) + val newNotificationProfileInserts: List = oldNotificationProfileStorageIds.keys + .mapNotNull { + val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", it) + SignalDatabase.notificationProfiles.getProfile(query) + } + .map { record -> StorageSyncModels.localToRemoteRecord(record, newNotificationProfileStorageIds[record.notificationProfileId]!!.raw) } + + inserts.addAll(newNotificationProfileInserts) + allNewStorageIds.addAll(newNotificationProfileStorageIds.values) + val recordIkm: RecordIkm? = if (Recipient.self().storageServiceEncryptionV2Capability.isSupported) { Log.i(TAG, "Generating and including a new recordIkm.") RecordIkm.generate() @@ -151,6 +165,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob( SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds) SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id)) SignalDatabase.chatFolders.applyStorageIdUpdates(newChatFolderStorageIds) + SignalDatabase.notificationProfiles.applyStorageIdUpdates(newNotificationProfileStorageIds) SignalDatabase.unknownStorageIds.deleteAll() } @@ -180,6 +195,12 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob( return out } + private fun generateNotificationProfileStorageIds(oldKeys: Map): Map { + return oldKeys.mapValues { (_, value) -> + value.withNewBytes(StorageSyncHelper.generateKey()) + } + } + class Factory : Job.Factory { override fun create(parameters: Parameters, serializedData: ByteArray?): StorageForcePushJob { return StorageForcePushJob(parameters) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index 0fec4307cc..64c7746096 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -9,6 +9,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.withinTransaction import org.signal.libsignal.protocol.InvalidKeyException import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable +import org.thoughtcrime.securesms.database.NotificationProfileTables import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.storage.ChatFolderRecordProcessor import org.thoughtcrime.securesms.storage.ContactRecordProcessor import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor +import org.thoughtcrime.securesms.storage.NotificationProfileRecordProcessor import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult import org.thoughtcrime.securesms.storage.StorageSyncModels @@ -40,6 +42,7 @@ import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord import org.whispersystems.signalservice.api.storage.SignalStorageManifest import org.whispersystems.signalservice.api.storage.SignalStorageRecord import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord @@ -53,6 +56,7 @@ import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord import org.whispersystems.signalservice.api.storage.toSignalContactRecord import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record +import org.whispersystems.signalservice.api.storage.toSignalNotificationProfileRecord import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord import org.whispersystems.signalservice.internal.push.SyncMessage import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord @@ -279,9 +283,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc if (idDifference.localOnlyIds.isNotEmpty()) { val updatedRecipients = SignalDatabase.recipients.removeStorageIdsFromLocalOnlyUnregisteredRecipients(idDifference.localOnlyIds) val updatedFolders = SignalDatabase.chatFolders.removeStorageIdsFromLocalOnlyDeletedFolders(idDifference.localOnlyIds) + val updatedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromLocalOnlyDeletedProfiles(idDifference.localOnlyIds) - if (updatedRecipients > 0 || updatedFolders > 0) { - Log.w(TAG, "Found $updatedRecipients recipients and $updatedFolders folders that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.") + if (updatedRecipients > 0 || updatedFolders > 0 || updatedProfiles > 0) { + Log.w(TAG, "Found $updatedRecipients recipients, $updatedFolders folders, $updatedProfiles notification profiles that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.") localStorageIdsBeforeMerge = getAllLocalStorageIds(self) idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge) @@ -310,7 +315,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc db.beginTransaction() try { - Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: ${remoteOnly.contacts.size}, GV1: ${remoteOnly.gv1.size}, GV2: ${remoteOnly.gv2.size}, Account: ${remoteOnly.account.size}, DLists: ${remoteOnly.storyDistributionLists.size}, call links: ${remoteOnly.callLinkRecords.size}, chat folders: ${remoteOnly.chatFolderRecords.size}") + Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: ${remoteOnly.contacts.size}, GV1: ${remoteOnly.gv1.size}, GV2: ${remoteOnly.gv2.size}, Account: ${remoteOnly.account.size}, DLists: ${remoteOnly.storyDistributionLists.size}, call links: ${remoteOnly.callLinkRecords.size}, chat folders: ${remoteOnly.chatFolderRecords.size}, notification profiles: ${remoteOnly.notificationProfileRecords.size}") processKnownRecords(context, remoteOnly) @@ -352,13 +357,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc self = freshSelf() val removedUnregistered = SignalDatabase.recipients.removeStorageIdsFromOldUnregisteredRecipients(System.currentTimeMillis()) - if (removedUnregistered > 0) { - Log.i(TAG, "Removed $removedUnregistered recipients from storage service that have been unregistered for longer than 30 days.") - } - val removedDeletedFolders = SignalDatabase.chatFolders.removeStorageIdsFromOldDeletedFolders(System.currentTimeMillis()) - if (removedDeletedFolders > 0) { - Log.i(TAG, "Removed $removedDeletedFolders folders from storage service that have been deleted for longer than 30 days.") + val removedDeletedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis()) + if (removedUnregistered > 0 || removedDeletedFolders > 0 || removedDeletedProfiles > 0) { + Log.i(TAG, "Removed $removedUnregistered unregistered, $removedDeletedFolders folders, $removedDeletedProfiles notification profiles from storage service that have been deleted for longer than 30 days.") } val localStorageIds = getAllLocalStorageIds(self) @@ -454,12 +456,14 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR) CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR) ChatFolderRecordProcessor().process(records.chatFolderRecords, StorageSyncHelper.KEY_GENERATOR) + NotificationProfileRecordProcessor().process(records.notificationProfileRecords, StorageSyncHelper.KEY_GENERATOR) } private fun getAllLocalStorageIds(self: Recipient): List { return SignalDatabase.recipients.getContactStorageSyncIds() + listOf(StorageId.forAccount(self.storageId)) + SignalDatabase.chatFolders.getStorageSyncIds() + + SignalDatabase.notificationProfiles.getStorageSyncIds() + SignalDatabase.unknownStorageIds.allUnknownIds } @@ -533,6 +537,16 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc } } + ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> { + val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.STORAGE_SERVICE_ID} = ?", Base64.encodeWithPadding(id.raw)) + val notificationProfile = SignalDatabase.notificationProfiles.getProfile(query) + if (notificationProfile?.notificationProfileId != null) { + records.add(StorageSyncModels.localToRemoteRecord(notificationProfile, id.raw)) + } else { + throw MissingNotificationProfileModelError("Missing local notification profile model! Type: " + id.type) + } + } + else -> { val unknown = SignalDatabase.unknownStorageIds.getById(id.raw) if (unknown != null) { @@ -567,6 +581,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc val storyDistributionLists: MutableList = mutableListOf() val callLinkRecords: MutableList = mutableListOf() val chatFolderRecords: MutableList = mutableListOf() + val notificationProfileRecords: MutableList = mutableListOf() init { for (record in records) { @@ -584,6 +599,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id) } else if (record.proto.chatFolder != null) { chatFolderRecords += record.proto.chatFolder!!.toSignalChatFolderRecord(record.id) + } else if (record.proto.notificationProfile != null) { + notificationProfileRecords += record.proto.notificationProfile!!.toSignalNotificationProfileRecord(record.id) } else if (record.id.isUnknown) { unknown += record } else { @@ -599,6 +616,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc private class MissingChatFolderModelError(message: String?) : Error(message) + private class MissingNotificationProfileModelError(message: String?) : Error(message) + private class MissingUnknownModelError(message: String?) : Error(message) class Factory : Job.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/profiles/NotificationProfile.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/profiles/NotificationProfile.kt index 61bfa9c8da..52eb0c16e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/profiles/NotificationProfile.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/profiles/NotificationProfile.kt @@ -2,20 +2,28 @@ package org.thoughtcrime.securesms.notifications.profiles import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.storage.StorageId data class NotificationProfile( val id: Long, val name: String, val emoji: String, - val color: AvatarColor = AvatarColor.A210, + val color: AvatarColor = DEFAULT_NOTIFICATION_PROFILE_COLOR, val createdAt: Long, val allowAllCalls: Boolean = true, val allowAllMentions: Boolean = false, val schedule: NotificationProfileSchedule, val allowedMembers: Set = emptySet(), - val notificationProfileId: NotificationProfileId + val notificationProfileId: NotificationProfileId, + val deletedTimestampMs: Long = 0, + val storageServiceId: StorageId? = null, + val storageServiceProto: ByteArray? = null ) : Comparable { + companion object { + val DEFAULT_NOTIFICATION_PROFILE_COLOR = AvatarColor.A210 + } + fun isRecipientAllowed(id: RecipientId): Boolean { return allowedMembers.contains(id) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt index becd97c41c..776aba4c87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt @@ -5,7 +5,6 @@ import okio.ByteString import org.signal.core.util.isNotEmpty import org.signal.core.util.logging.Log import org.signal.core.util.nullIfEmpty -import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates import org.whispersystems.signalservice.api.storage.IAPSubscriptionId @@ -125,7 +124,6 @@ class AccountRecordProcessor( preferContactAvatars = remote.proto.preferContactAvatars universalExpireTimer = remote.proto.universalExpireTimer primarySendsSms = false - e164 = if (SignalStore.account.isPrimaryDevice) local.proto.e164 else remote.proto.e164 preferredReactionEmoji = remote.proto.preferredReactionEmoji.takeIf { it.isNotEmpty() } ?: local.proto.preferredReactionEmoji displayBadgesOnProfile = remote.proto.displayBadgesOnProfile subscriptionManuallyCancelled = remote.proto.subscriptionManuallyCancelled @@ -138,6 +136,7 @@ class AccountRecordProcessor( hasCompletedUsernameOnboarding = remote.proto.hasCompletedUsernameOnboarding || local.proto.hasCompletedUsernameOnboarding username = remote.proto.username usernameLink = remote.proto.usernameLink + notificationProfileManualOverride = remote.proto.notificationProfileManualOverride safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray()) safeSetSubscriber(donationSubscriberId, donationSubscriberCurrencyCode) diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessor.kt index 99f25d4a5b..b9bf942555 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessor.kt @@ -11,6 +11,7 @@ import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord +import org.whispersystems.signalservice.internal.storage.protos.Recipient import java.util.Optional import java.util.UUID @@ -96,7 +97,7 @@ class ChatFolderRecordProcessor : DefaultStorageRecordProcessor): Boolean { + private fun containsInvalidServiceId(recipients: List): Boolean { return recipients.any { recipient -> recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessor.kt new file mode 100644 index 0000000000..9505f63458 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessor.kt @@ -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() { + + 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 { + 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() + } + } + + /** + * 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) { + SignalDatabase.notificationProfiles.updateNotificationProfileFromStorageSync(update.new) + } + + private fun containsInvalidServiceId(recipients: List): Boolean { + return recipients.any { recipient -> + recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt index 085be1aae3..c1fc4497bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt @@ -5,10 +5,12 @@ import androidx.annotation.VisibleForTesting import okio.ByteString import okio.ByteString.Companion.toByteString import org.signal.core.util.Base64.encodeWithPadding +import org.signal.core.util.SqlUtil import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.getSubscriber import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.isUserManuallyCancelled import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.setSubscriber +import org.thoughtcrime.securesms.database.NotificationProfileTables import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.RecipientRecord @@ -18,6 +20,7 @@ import org.thoughtcrime.securesms.jobs.StorageSyncJob import org.thoughtcrime.securesms.keyvalue.AccountValues import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId import org.thoughtcrime.securesms.payments.Entropy import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient.Companion.self @@ -171,6 +174,7 @@ object StorageSyncHelper { color = StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc.usernameQrCodeColorScheme) ) } + notificationProfileManualOverride = getNotificationProfileManualOverride() getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)?.let { safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency?.currencyCode ?: "") @@ -186,6 +190,24 @@ object StorageSyncHelper { return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord() } + private fun getNotificationProfileManualOverride(): AccountRecord.NotificationProfileManualOverride { + val profile = SignalDatabase.notificationProfiles.getProfile(SignalStore.notificationProfile.manuallyEnabledProfile) + return if (profile != null && profile.deletedTimestampMs == 0L) { + // From [StorageService.proto], end timestamp should be unset if no timespan was chosen in the UI + val endTimestamp = if (SignalStore.notificationProfile.manuallyEnabledUntil == Long.MAX_VALUE) 0 else SignalStore.notificationProfile.manuallyEnabledUntil + AccountRecord.NotificationProfileManualOverride( + enabled = AccountRecord.NotificationProfileManualOverride.ManuallyEnabled( + id = UuidUtil.toByteArray(profile.notificationProfileId.uuid).toByteString(), + endAtTimestampMs = endTimestamp + ) + ) + } else { + AccountRecord.NotificationProfileManualOverride( + disabledAtTimestampMs = SignalStore.notificationProfile.manuallyDisabledAt + ) + } + } + @JvmStatic fun applyAccountStorageSyncUpdates(context: Context, self: Recipient, updatedRecord: SignalAccountRecord, fetchProfile: Boolean) { val localRecord = buildAccountRecord(context, self).let { it.proto.account!!.toSignalAccountRecord(it.id) } @@ -252,6 +274,33 @@ object StorageSyncHelper { SignalStore.misc.usernameQrCodeColorScheme = StorageSyncModels.remoteToLocalUsernameColor(update.new.proto.usernameLink!!.color) } + + if (update.new.proto.notificationProfileManualOverride != null) { + if (update.new.proto.notificationProfileManualOverride!!.enabled != null) { + val remoteProfile = update.new.proto.notificationProfileManualOverride!!.enabled!! + val remoteId = UuidUtil.parseOrNull(remoteProfile.id) + val remoteEndTime = if (remoteProfile.endAtTimestampMs == 0L) Long.MAX_VALUE else remoteProfile.endAtTimestampMs + + if (remoteId == null) { + Log.w(TAG, "Remote notification profile id is not valid") + } else { + val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", NotificationProfileId(remoteId)) + val localProfile = SignalDatabase.notificationProfiles.getProfile(query) + + if (localProfile == null) { + Log.w(TAG, "Unable to find local notification profile with given remote id") + } else { + SignalStore.notificationProfile.manuallyEnabledProfile = localProfile.id + SignalStore.notificationProfile.manuallyEnabledUntil = remoteEndTime + SignalStore.notificationProfile.manuallyDisabledAt = System.currentTimeMillis() + } + } + } else { + SignalStore.notificationProfile.manuallyEnabledProfile = 0 + SignalStore.notificationProfile.manuallyEnabledUntil = 0 + SignalStore.notificationProfile.manuallyDisabledAt = update.new.proto.notificationProfileManualOverride!!.disabledAtTimestampMs!! + } + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt index fd3e999e39..ba2c47ca9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -4,6 +4,8 @@ import okio.ByteString import okio.ByteString.Companion.toByteString import org.signal.core.util.isNotEmpty import org.signal.core.util.isNullOrEmpty +import org.signal.core.util.logging.Log +import org.signal.libsignal.zkgroup.InvalidInputException import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme @@ -20,14 +22,21 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.inAppPayment import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.RecipientRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.groups.BadGroupIdException +import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord import org.whispersystems.signalservice.api.storage.SignalStorageRecord import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord import org.whispersystems.signalservice.api.storage.StorageId @@ -36,6 +45,7 @@ import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord import org.whispersystems.signalservice.api.storage.toSignalContactRecord import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record +import org.whispersystems.signalservice.api.storage.toSignalNotificationProfileRecord import org.whispersystems.signalservice.api.storage.toSignalStorageRecord import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord import org.whispersystems.signalservice.api.subscriptions.SubscriberId @@ -44,13 +54,18 @@ import org.whispersystems.signalservice.internal.storage.protos.AccountRecord import org.whispersystems.signalservice.internal.storage.protos.ContactRecord import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record +import java.time.DayOfWeek import java.util.Currency import kotlin.math.max import org.whispersystems.signalservice.internal.storage.protos.AvatarColor as RemoteAvatarColor import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolder +import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek as RemoteDayOfWeek +import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient object StorageSyncModels { + private val TAG = Log.tag(StorageSyncModels::class.java) + fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord { if (settings.storageId == null) { throw AssertionError("Must have a storage key!") @@ -82,6 +97,10 @@ object StorageSyncModels { return localToRemoteChatFolder(folder, rawStorageId).toSignalStorageRecord() } + fun localToRemoteRecord(profile: NotificationProfile, rawStorageId: ByteArray): SignalStorageRecord { + return localToRemoteNotificationProfile(profile, rawStorageId).toSignalStorageRecord() + } + @JvmStatic fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode { return when (phoneNumberPhoneNumberSharingMode) { @@ -396,22 +415,96 @@ object StorageSyncModels { }.build().toSignalChatFolderRecord(StorageId.forChatFolder(rawStorageId)) } - private fun localToRemoteChatFolderRecipients(threadIds: List): List { + 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): List { + 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): List { val recipientIds = SignalDatabase.threads.getRecipientIdsForThreadIds(threadIds) + return localToRemoteRecipients(recipientIds) + } + + private fun localToRemoteRecipients(recipientIds: List): List { return recipientIds.mapNotNull { id -> val recipient = SignalDatabase.recipients.getRecordForSync(id) ?: throw AssertionError("Missing recipient for id") when (recipient.recipientType) { RecipientType.INDIVIDUAL -> { - RemoteChatFolder.Recipient(contact = RemoteChatFolder.Recipient.Contact(serviceId = recipient.serviceId?.toString() ?: "", e164 = recipient.e164 ?: "")) + RemoteRecipient(contact = RemoteRecipient.Contact(serviceId = recipient.serviceId?.toString() ?: "", e164 = recipient.e164 ?: "")) } RecipientType.GV1 -> { - RemoteChatFolder.Recipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString()) + RemoteRecipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString()) } RecipientType.GV2 -> { - RemoteChatFolder.Recipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString()) + RemoteRecipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString()) } else -> null } } } + + fun remoteToLocalRecipient(remoteRecipient: RemoteRecipient): Recipient? { + return if (remoteRecipient.contact != null) { + val serviceId = ServiceId.parseOrNull(remoteRecipient.contact!!.serviceId) + val e164 = remoteRecipient.contact!!.e164 + Recipient.externalPush(SignalServiceAddress(serviceId, e164)) + } else if (remoteRecipient.legacyGroupId != null) { + try { + Recipient.externalGroupExact(GroupId.v1(remoteRecipient.legacyGroupId!!.toByteArray())) + } catch (e: BadGroupIdException) { + Log.w(TAG, "Failed to parse groupV1 ID!", e) + null + } + } else if (remoteRecipient.groupMasterKey != null) { + try { + Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(remoteRecipient.groupMasterKey!!.toByteArray()))) + } catch (e: InvalidInputException) { + Log.w(TAG, "Failed to parse groupV2 master key!", e) + null + } + } else { + Log.w(TAG, "Could not find recipient") + null + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java index 8429030c32..5844fb97ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java @@ -156,6 +156,11 @@ public final class StorageSyncValidations { throw new DuplicateChatFolderError(); } + ids = manifest.getStorageIdsByType().get(ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue()); + if (ids.size() != new HashSet<>(ids).size()) { + throw new DuplicateNotificationProfileError(); + } + throw new DuplicateRawIdAcrossTypesError(); } @@ -217,6 +222,9 @@ public final class StorageSyncValidations { private static final class DuplicateInsertInWriteError extends Error { } + private static final class DuplicateNotificationProfileError extends Error { + } + private static final class InsertNotPresentInFullIdSetError extends Error { } diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt index b7f272151a..1af4413a05 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt @@ -52,6 +52,9 @@ class StorageServicePlugin : Plugin { } else if (record.proto.chatFolder != null) { row += "Chat Folder" row += record.proto.chatFolder.toString() + } else if (record.proto.notificationProfile != null) { + row += "Notification Profile" + row += record.proto.notificationProfile.toString() } else { row += "Unknown" row += "" diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessorTest.kt index 9aa6100d24..545505d668 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/ChatFolderRecordProcessorTest.kt @@ -11,6 +11,7 @@ import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord +import org.whispersystems.signalservice.internal.storage.protos.Recipient import java.util.UUID /** @@ -203,7 +204,7 @@ class ChatFolderRecordProcessorTest { includeAllGroupChats = false folderType = ChatFolderRecord.FolderType.CUSTOM deletedAtTimestampMs = 0L - includedRecipients = listOf(ChatFolderRecord.Recipient(contact = ChatFolderRecord.Recipient.Contact(serviceId = "bad"))) + includedRecipients = listOf(Recipient(contact = Recipient.Contact(serviceId = "bad"))) }.build() val record = SignalChatFolderRecord(STORAGE_ID, proto) diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessorTest.kt new file mode 100644 index 0000000000..76cdc71aba --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/NotificationProfileRecordProcessorTest.kt @@ -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) + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalNotificationProfileRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalNotificationProfileRecord.kt new file mode 100644 index 0000000000..c400962e89 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalNotificationProfileRecord.kt @@ -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 { + + 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() + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt index 644c6d65a9..47f28cda22 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt @@ -10,7 +10,7 @@ data class SignalStorageRecord( val proto: StorageRecord ) { val isUnknown: Boolean - get() = proto.contact == null && proto.groupV1 == null && proto.groupV2 == null && proto.account == null && proto.storyDistributionList == null && proto.callLink == null && proto.chatFolder == null + get() = proto.contact == null && proto.groupV1 == null && proto.groupV2 == null && proto.account == null && proto.storyDistributionList == null && proto.callLink == null && proto.chatFolder == null && proto.notificationProfile == null companion object { @JvmStatic diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java index 79417b3669..fe5588cb87 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java @@ -42,6 +42,10 @@ public class StorageId { return new StorageId(ManifestRecord.Identifier.Type.CHAT_FOLDER.getValue(), Preconditions.checkNotNull(raw)); } + public static StorageId forNotificationProfile(byte[] raw) { + return new StorageId(ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue(), Preconditions.checkNotNull(raw)); + } + public static StorageId forType(byte[] raw, int type) { return new StorageId(type, raw); } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt index b58acd56e8..408b2f91ad 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt @@ -11,6 +11,7 @@ import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord import org.whispersystems.signalservice.internal.storage.protos.ContactRecord import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record +import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile import org.whispersystems.signalservice.internal.storage.protos.StorageRecord import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord @@ -46,6 +47,10 @@ fun ChatFolderRecord.toSignalChatFolderRecord(storageId: StorageId): SignalChatF return SignalChatFolderRecord(storageId, this) } +fun NotificationProfile.toSignalNotificationProfileRecord(storageId: StorageId): SignalNotificationProfileRecord { + return SignalNotificationProfileRecord(storageId, this) +} + fun SignalContactRecord.toSignalStorageRecord(): SignalStorageRecord { return SignalStorageRecord(id, StorageRecord(contact = this.proto)) } @@ -73,3 +78,7 @@ fun SignalCallLinkRecord.toSignalStorageRecord(): SignalStorageRecord { fun SignalChatFolderRecord.toSignalStorageRecord(): SignalStorageRecord { return SignalStorageRecord(id, StorageRecord(chatFolder = this.proto)) } + +fun SignalNotificationProfileRecord.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(notificationProfile = this.proto)) +} diff --git a/libsignal-service/src/main/protowire/StorageService.proto b/libsignal-service/src/main/protowire/StorageService.proto index de8517f675..eaa145346c 100644 --- a/libsignal-service/src/main/protowire/StorageService.proto +++ b/libsignal-service/src/main/protowire/StorageService.proto @@ -52,6 +52,7 @@ message ManifestRecord { STORY_DISTRIBUTION_LIST = 5; CALL_LINK = 7; CHAT_FOLDER = 8; + NOTIFICATION_PROFILE = 9; } bytes raw = 1; @@ -74,6 +75,7 @@ message StorageRecord { StoryDistributionListRecord storyDistributionList = 5; CallLinkRecord callLink = 7; ChatFolderRecord chatFolder = 8; + NotificationProfile notificationProfile = 9; } } @@ -225,48 +227,70 @@ message AccountRecord { } } - bytes profileKey = 1; - string givenName = 2; - string familyName = 3; - string avatarUrlPath = 4; - bool noteToSelfArchived = 5; - bool readReceipts = 6; - bool sealedSenderIndicators = 7; - bool typingIndicators = 8; - reserved /* proxiedLinkPreviews */ 9; - bool noteToSelfMarkedUnread = 10; - bool linkPreviews = 11; - PhoneNumberSharingMode phoneNumberSharingMode = 12; - bool unlistedPhoneNumber = 13; - repeated PinnedConversation pinnedConversations = 14; - bool preferContactAvatars = 15; - Payments payments = 16; - uint32 universalExpireTimer = 17; - bool primarySendsSms = 18; - string e164 = 19; - repeated string preferredReactionEmoji = 20; - bytes subscriberId = 21; - string subscriberCurrencyCode = 22; - bool displayBadgesOnProfile = 23; - bool subscriptionManuallyCancelled = 24; - bool keepMutedChatsArchived = 25; - bool hasSetMyStoriesPrivacy = 26; - bool hasViewedOnboardingStory = 27; - reserved /* storiesDisabled */ 28; - bool storiesDisabled = 29; - OptionalBool storyViewReceiptsEnabled = 30; - reserved /* hasReadOnboardingStory */ 31; - bool hasSeenGroupStoryEducationSheet = 32; - string username = 33; - bool hasCompletedUsernameOnboarding = 34; - UsernameLink usernameLink = 35; - reserved /* backupsSubscriberId */ 36; - reserved /* backupsSubscriberCurrencyCode */ 37; - reserved /* backupsSubscriptionManuallyCancelled */ 38; - optional bool hasBackup = 39; // Set to true after backups are enabled and one is uploaded. - optional uint64 backupTier = 40; // See zkgroup for integer particular values - IAPSubscriberData backupSubscriberData = 41; - optional AvatarColor avatarColor = 42; + message BackupTierHistory { + // See zkgroup for integer particular values. Unset if backups are not enabled. + optional uint64 backupTier = 1; + optional uint64 endedAtTimestamp = 2; + } + + message NotificationProfileManualOverride { + message ManuallyEnabled { + bytes id = 1; + + // This will be unset if no timespan was chosen in the UI. + uint64 endAtTimestampMs = 3; + } + + oneof override { + uint64 disabledAtTimestampMs = 1; + ManuallyEnabled enabled = 2; + } + } + + bytes profileKey = 1; + string givenName = 2; + string familyName = 3; + string avatarUrlPath = 4; + bool noteToSelfArchived = 5; + bool readReceipts = 6; + bool sealedSenderIndicators = 7; + bool typingIndicators = 8; + reserved /* proxiedLinkPreviews */ 9; + bool noteToSelfMarkedUnread = 10; + bool linkPreviews = 11; + PhoneNumberSharingMode phoneNumberSharingMode = 12; + bool unlistedPhoneNumber = 13; + repeated PinnedConversation pinnedConversations = 14; + bool preferContactAvatars = 15; + Payments payments = 16; + uint32 universalExpireTimer = 17; + bool primarySendsSms = 18; + reserved /* e164 */ 19; + repeated string preferredReactionEmoji = 20; + bytes subscriberId = 21; + string subscriberCurrencyCode = 22; + bool displayBadgesOnProfile = 23; + bool subscriptionManuallyCancelled = 24; + bool keepMutedChatsArchived = 25; + bool hasSetMyStoriesPrivacy = 26; + bool hasViewedOnboardingStory = 27; + reserved /* storiesDisabled */ 28; + bool storiesDisabled = 29; + OptionalBool storyViewReceiptsEnabled = 30; + reserved /* hasReadOnboardingStory */ 31; + bool hasSeenGroupStoryEducationSheet = 32; + string username = 33; + bool hasCompletedUsernameOnboarding = 34; + UsernameLink usernameLink = 35; + reserved /* backupsSubscriberId */ 36; + reserved /* backupsSubscriberCurrencyCode */ 37; + reserved /* backupsSubscriptionManuallyCancelled */ 38; + optional bool hasBackup = 39; // Set to true after backups are enabled and one is uploaded. + optional uint64 backupTier = 40; // See zkgroup for integer particular values. Unset if backups are not enabled. + IAPSubscriberData backupSubscriberData = 41; + optional AvatarColor avatarColor = 42; + BackupTierHistory backupTierHistory = 43; + NotificationProfileManualOverride notificationProfileManualOverride = 44; } message StoryDistributionListRecord { @@ -284,20 +308,20 @@ message CallLinkRecord { uint64 deletedAtTimestampMs = 3; } -message ChatFolderRecord { - message Recipient { - message Contact { - string serviceId = 1; - string e164 = 2; - } - - oneof identifier { - Contact contact = 1; - bytes legacyGroupId = 2; - bytes groupMasterKey = 3; - } +message Recipient { + message Contact { + string serviceId = 1; + string e164 = 2; } + oneof identifier { + Contact contact = 1; + bytes legacyGroupId = 2; + bytes groupMasterKey = 3; + } +} + +message ChatFolderRecord { // Represents the default "All chats" folder record vs all other custom folders enum FolderType { UNKNOWN = 0; @@ -317,3 +341,30 @@ message ChatFolderRecord { repeated Recipient excludedRecipients = 10; uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and includedRecipients should be empty } + +message NotificationProfile { + enum DayOfWeek { + UNKNOWN = 0; // Interpret as "Monday" + MONDAY = 1; + TUESDAY = 2; + WEDNESDAY = 3; + THURSDAY = 4; + FRIDAY = 5; + SATURDAY = 6; + SUNDAY = 7; + } + + bytes id = 1; + string name = 2; + optional string emoji = 3; + fixed32 color = 4; // 0xAARRGGBB + uint64 createdAtMs = 5; + bool allowAllCalls = 6; + bool allowAllMentions = 7; + repeated Recipient allowedMembers = 8; + bool scheduleEnabled = 9; + uint32 scheduleStartTime = 10; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) + uint32 scheduleEndTime = 11; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) + repeated DayOfWeek scheduleDaysEnabled = 12; + uint64 deletedAtTimestampMs = 13; +}