mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 12:38:33 +00:00
Add notification profiles to storage service.
This commit is contained in:
committed by
Cody Henthorne
parent
07d961fc09
commit
e3ee3d3dba
@@ -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()))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
|
||||
@@ -56,36 +58,43 @@ class NotificationProfilesRepository {
|
||||
|
||||
fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> {
|
||||
return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) }
|
||||
.doOnSuccess { StorageSyncHelper.scheduleSyncForDataChange() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileTables.NotificationProfileChangeResult> {
|
||||
return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) }
|
||||
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateProfile(profile: NotificationProfile): Single<NotificationProfileTables.NotificationProfileChangeResult> {
|
||||
return Single.fromCallable { database.updateProfile(profile) }
|
||||
.doOnSuccess { scheduleNotificationProfileSync(profile.id) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> {
|
||||
return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) }
|
||||
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
|
||||
return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) }
|
||||
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
|
||||
return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) }
|
||||
.doOnSuccess { scheduleNotificationProfileSync(profileId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteProfile(profileId: Long): Completable {
|
||||
return Completable.fromCallable { database.deleteProfile(profileId) }
|
||||
.doOnComplete { scheduleNotificationProfileSync(profileId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@@ -132,7 +141,10 @@ class NotificationProfilesRepository {
|
||||
SignalStore.notificationProfile.manuallyDisabledAt = now
|
||||
}
|
||||
}
|
||||
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() }
|
||||
.doOnComplete {
|
||||
scheduleManualOverrideSync()
|
||||
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@@ -142,7 +154,10 @@ class NotificationProfilesRepository {
|
||||
SignalStore.notificationProfile.manuallyEnabledUntil = enableUntil
|
||||
SignalStore.notificationProfile.manuallyDisabledAt = now
|
||||
}
|
||||
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() }
|
||||
.doOnComplete {
|
||||
scheduleManualOverrideSync()
|
||||
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@@ -153,9 +168,28 @@ class NotificationProfilesRepository {
|
||||
SignalStore.notificationProfile.manuallyEnabledUntil = if (inScheduledWindow) schedule.endDateTime(now.toLocalDateTime()).toMillis() else Long.MAX_VALUE
|
||||
SignalStore.notificationProfile.manuallyDisabledAt = if (inScheduledWindow) now else 0
|
||||
}
|
||||
.doOnComplete { AppDependencies.databaseObserver.notifyNotificationProfileObservers() }
|
||||
.doOnComplete {
|
||||
scheduleManualOverrideSync()
|
||||
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a sync for a notification profile when it changes
|
||||
*/
|
||||
fun scheduleNotificationProfileSync(profileId: Long) {
|
||||
SignalDatabase.notificationProfiles.markNeedsSync(profileId)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a sync for the self when the manual notification profile changes
|
||||
*/
|
||||
private fun scheduleManualOverrideSync() {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
class NotificationProfileNotFoundException : Throwable()
|
||||
}
|
||||
|
||||
@@ -24,18 +24,12 @@ import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.database.ThreadTable.Companion.ID
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncModels
|
||||
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
@@ -684,10 +678,10 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
|
||||
RemoteChatFolderRecord.FolderType.UNKNOWN -> throw AssertionError("Folder type cannot be unknown")
|
||||
},
|
||||
includedChats = record.proto.includedRecipients
|
||||
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) }
|
||||
.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
|
||||
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
|
||||
excludedChats = record.proto.excludedRecipients
|
||||
.mapNotNull { remoteRecipient -> getRecipientIdFromRemoteRecipient(remoteRecipient) }
|
||||
.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
|
||||
.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) },
|
||||
chatFolderId = chatFolderId,
|
||||
storageServiceId = StorageId.forChatFolder(record.id.raw),
|
||||
@@ -696,34 +690,6 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a remote recipient into a local one. Used when configuring the chats of a remote chat folder into a local one.
|
||||
*/
|
||||
private fun getRecipientIdFromRemoteRecipient(remoteRecipient: RemoteChatFolderRecord.Recipient): Recipient? {
|
||||
return if (remoteRecipient.contact != null) {
|
||||
val serviceId = ServiceId.parseOrNull(remoteRecipient.contact!!.serviceId)
|
||||
val e164 = remoteRecipient.contact!!.e164
|
||||
Recipient.externalPush(SignalServiceAddress(serviceId, e164))
|
||||
} else if (remoteRecipient.legacyGroupId != null) {
|
||||
try {
|
||||
Recipient.externalGroupExact(GroupId.v1(remoteRecipient.legacyGroupId!!.toByteArray()))
|
||||
} catch (e: BadGroupIdException) {
|
||||
Log.w(TAG, "Failed to parse groupV1 ID!", e)
|
||||
null
|
||||
}
|
||||
} else if (remoteRecipient.groupMasterKey != null) {
|
||||
try {
|
||||
Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(remoteRecipient.groupMasterKey!!.toByteArray())))
|
||||
} catch (e: InvalidInputException) {
|
||||
Log.w(TAG, "Failed to parse groupV2 master key!", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not find recipient")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Collection<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
|
||||
return map {
|
||||
contentValuesOf(
|
||||
|
||||
@@ -5,23 +5,40 @@ package org.thoughtcrime.securesms.database
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.hasUnknownFields
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToMap
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncModels
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncModels.toLocal
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.time.DayOfWeek
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Database for maintaining Notification Profiles, Notification Profile Schedules, and Notification Profile allowed memebers.
|
||||
@@ -30,6 +47,7 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(NotificationProfileTable::class)
|
||||
private val DELETED_LIFESPAN: Long = 30.days.inWholeMilliseconds
|
||||
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(NotificationProfileTable.CREATE_TABLE, NotificationProfileScheduleTable.CREATE_TABLE, NotificationProfileAllowedMembersTable.CREATE_TABLE)
|
||||
@@ -49,17 +67,23 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
const val ALLOW_ALL_CALLS = "allow_all_calls"
|
||||
const val ALLOW_ALL_MENTIONS = "allow_all_mentions"
|
||||
const val NOTIFICATION_PROFILE_ID = "notification_profile_id"
|
||||
const val DELETED_TIMESTAMP_MS = "deleted_timestamp_ms"
|
||||
const val STORAGE_SERVICE_ID = "storage_service_id"
|
||||
const val STORAGE_SERVICE_PROTO = "storage_service_proto"
|
||||
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$NAME TEXT NOT NULL UNIQUE,
|
||||
$NAME TEXT NOT NULL,
|
||||
$EMOJI TEXT NOT NULL,
|
||||
$COLOR TEXT NOT NULL,
|
||||
$CREATED_AT INTEGER NOT NULL,
|
||||
$ALLOW_ALL_CALLS INTEGER NOT NULL DEFAULT 0,
|
||||
$ALLOW_ALL_MENTIONS INTEGER NOT NULL DEFAULT 0,
|
||||
$NOTIFICATION_PROFILE_ID TEXT DEFAULT NULL
|
||||
$NOTIFICATION_PROFILE_ID TEXT DEFAULT NULL,
|
||||
$DELETED_TIMESTAMP_MS INTEGER DEFAULT 0,
|
||||
$STORAGE_SERVICE_ID TEXT DEFAULT NULL,
|
||||
$STORAGE_SERVICE_PROTO TEXT DEFAULT NULL
|
||||
)
|
||||
"""
|
||||
}
|
||||
@@ -114,7 +138,12 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
if (isDuplicateName(name)) {
|
||||
return NotificationProfileChangeResult.DuplicateName
|
||||
}
|
||||
|
||||
val notificationProfileId = NotificationProfileId.generate()
|
||||
val storageServiceId = StorageSyncHelper.generateKey()
|
||||
val profileValues = ContentValues().apply {
|
||||
put(NotificationProfileTable.NAME, name)
|
||||
put(NotificationProfileTable.EMOJI, emoji)
|
||||
@@ -122,12 +151,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
put(NotificationProfileTable.CREATED_AT, createdAt)
|
||||
put(NotificationProfileTable.ALLOW_ALL_CALLS, 1)
|
||||
put(NotificationProfileTable.NOTIFICATION_PROFILE_ID, notificationProfileId.serialize())
|
||||
put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId))
|
||||
}
|
||||
|
||||
val profileId = db.insert(NotificationProfileTable.TABLE_NAME, null, profileValues)
|
||||
if (profileId < 0) {
|
||||
return NotificationProfileChangeResult.DuplicateName
|
||||
}
|
||||
|
||||
val scheduleValues = ContentValues().apply {
|
||||
put(NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID, profileId)
|
||||
@@ -147,7 +174,8 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
createdAt = createdAt,
|
||||
schedule = getProfileSchedule(profileId),
|
||||
allowAllCalls = true,
|
||||
notificationProfileId = notificationProfileId
|
||||
notificationProfileId = notificationProfileId,
|
||||
storageServiceId = StorageId.forNotificationProfile(storageServiceId)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
@@ -157,6 +185,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
}
|
||||
|
||||
fun updateProfile(profileId: Long, name: String, emoji: String): NotificationProfileChangeResult {
|
||||
if (isDuplicateName(name, profileId)) {
|
||||
return NotificationProfileChangeResult.DuplicateName
|
||||
}
|
||||
|
||||
val profileValues = ContentValues().apply {
|
||||
put(NotificationProfileTable.NAME, name)
|
||||
put(NotificationProfileTable.EMOJI, emoji)
|
||||
@@ -164,37 +196,38 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
|
||||
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profileId), profileValues)
|
||||
|
||||
return try {
|
||||
val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
|
||||
if (count > 0) {
|
||||
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
|
||||
}
|
||||
|
||||
NotificationProfileChangeResult.Success(getProfile(profileId)!!)
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
NotificationProfileChangeResult.DuplicateName
|
||||
}
|
||||
return NotificationProfileChangeResult.Success(getProfile(profileId)!!)
|
||||
}
|
||||
|
||||
fun updateProfile(profile: NotificationProfile): NotificationProfileChangeResult {
|
||||
if (isDuplicateName(profile.name, profile.id)) {
|
||||
return NotificationProfileChangeResult.DuplicateName
|
||||
}
|
||||
|
||||
val db = writableDatabase
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
val storageServiceId = profile.storageServiceId?.raw ?: StorageSyncHelper.generateKey()
|
||||
val storageServiceProto = if (profile.storageServiceProto != null) Base64.encodeWithPadding(profile.storageServiceProto) else null
|
||||
|
||||
val profileValues = ContentValues().apply {
|
||||
put(NotificationProfileTable.NAME, profile.name)
|
||||
put(NotificationProfileTable.EMOJI, profile.emoji)
|
||||
put(NotificationProfileTable.ALLOW_ALL_CALLS, profile.allowAllCalls.toInt())
|
||||
put(NotificationProfileTable.ALLOW_ALL_MENTIONS, profile.allowAllMentions.toInt())
|
||||
put(NotificationProfileTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(storageServiceId))
|
||||
put(NotificationProfileTable.STORAGE_SERVICE_PROTO, storageServiceProto)
|
||||
}
|
||||
|
||||
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profile.id), profileValues)
|
||||
|
||||
try {
|
||||
db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
return NotificationProfileChangeResult.DuplicateName
|
||||
}
|
||||
|
||||
updateSchedule(profile.schedule, true)
|
||||
|
||||
@@ -280,16 +313,16 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
return getProfile(profileId)!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all undeleted notification profiles
|
||||
*/
|
||||
fun getProfiles(): List<NotificationProfile> {
|
||||
val profiles: MutableList<NotificationProfile> = mutableListOf()
|
||||
|
||||
readableDatabase.query(NotificationProfileTable.TABLE_NAME, null, null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
profiles += getProfile(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
return profiles
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from(NotificationProfileTable.TABLE_NAME)
|
||||
.where("${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0")
|
||||
.run()
|
||||
.readToList { cursor -> getProfile(cursor) }
|
||||
}
|
||||
|
||||
fun getProfile(profileId: Long): NotificationProfile? {
|
||||
@@ -302,11 +335,196 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
}
|
||||
}
|
||||
|
||||
fun getProfile(query: SqlUtil.Query): NotificationProfile? {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from(NotificationProfileTable.TABLE_NAME)
|
||||
.where(query.where, query.whereArgs)
|
||||
.run()
|
||||
.readToSingleObject { cursor -> getProfile(cursor) }
|
||||
}
|
||||
|
||||
fun deleteProfile(profileId: Long) {
|
||||
writableDatabase.delete(NotificationProfileTable.TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(profileId))
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.update(NotificationProfileTable.TABLE_NAME)
|
||||
.values(NotificationProfileTable.DELETED_TIMESTAMP_MS to System.currentTimeMillis())
|
||||
.where("${NotificationProfileTable.ID} = ?", profileId)
|
||||
.run()
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
|
||||
}
|
||||
|
||||
fun markNeedsSync(profileId: Long) {
|
||||
writableDatabase.withinTransaction {
|
||||
rotateStorageId(profileId)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyStorageIdUpdate(id: NotificationProfileId, storageId: StorageId) {
|
||||
applyStorageIdUpdates(hashMapOf(id to storageId))
|
||||
}
|
||||
|
||||
fun applyStorageIdUpdates(storageIds: Map<NotificationProfileId, StorageId>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
storageIds.forEach { (notificationProfileId, storageId) ->
|
||||
db.update(NotificationProfileTable.TABLE_NAME)
|
||||
.values(NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId.raw))
|
||||
.where("${NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", notificationProfileId.serialize())
|
||||
.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun insertNotificationProfileFromStorageSync(notificationProfileRecord: SignalNotificationProfileRecord) {
|
||||
val profile = notificationProfileRecord.proto
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val storageServiceProto = if (notificationProfileRecord.proto.hasUnknownFields()) Base64.encodeWithPadding(notificationProfileRecord.serializedUnknowns!!) else null
|
||||
|
||||
val id = db.insertInto(NotificationProfileTable.TABLE_NAME)
|
||||
.values(
|
||||
contentValuesOf(
|
||||
NotificationProfileTable.NAME to profile.name,
|
||||
NotificationProfileTable.EMOJI to profile.emoji.orEmpty(),
|
||||
NotificationProfileTable.COLOR to (AvatarColor.fromColor(profile.color)?.serialize() ?: NotificationProfile.DEFAULT_NOTIFICATION_PROFILE_COLOR.serialize()),
|
||||
NotificationProfileTable.CREATED_AT to profile.createdAtMs,
|
||||
NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls,
|
||||
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions,
|
||||
NotificationProfileTable.NOTIFICATION_PROFILE_ID to NotificationProfileId(UuidUtil.parseOrThrow(profile.id)).serialize(),
|
||||
NotificationProfileTable.DELETED_TIMESTAMP_MS to profile.deletedAtTimestampMs,
|
||||
NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(notificationProfileRecord.id.raw),
|
||||
NotificationProfileTable.STORAGE_SERVICE_PROTO to storageServiceProto
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
db.insertInto(NotificationProfileScheduleTable.TABLE_NAME)
|
||||
.values(
|
||||
contentValuesOf(
|
||||
NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID to id,
|
||||
NotificationProfileScheduleTable.ENABLED to profile.scheduleEnabled.toInt(),
|
||||
NotificationProfileScheduleTable.START to profile.scheduleStartTime,
|
||||
NotificationProfileScheduleTable.END to profile.scheduleEndTime,
|
||||
NotificationProfileScheduleTable.DAYS_ENABLED to profile.scheduleDaysEnabled.map { it.toLocal() }.toSet().serialize()
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
profile.allowedMembers
|
||||
.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient) }
|
||||
.forEach {
|
||||
db.insertInto(NotificationProfileAllowedMembersTable.TABLE_NAME)
|
||||
.values(
|
||||
NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID to id,
|
||||
NotificationProfileAllowedMembersTable.RECIPIENT_ID to it.id.serialize()
|
||||
)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyNotificationProfileObservers()
|
||||
}
|
||||
|
||||
fun updateNotificationProfileFromStorageSync(notificationProfileRecord: SignalNotificationProfileRecord) {
|
||||
val profile = notificationProfileRecord.proto
|
||||
val notificationProfileId = NotificationProfileId(UuidUtil.parseOrThrow(profile.id))
|
||||
|
||||
val profileId = readableDatabase
|
||||
.select(NotificationProfileTable.ID)
|
||||
.from(NotificationProfileTable.TABLE_NAME)
|
||||
.where("${NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", notificationProfileId.serialize())
|
||||
.run()
|
||||
.readToSingleLong()
|
||||
|
||||
val scheduleId = readableDatabase
|
||||
.select(NotificationProfileScheduleTable.ID)
|
||||
.from(NotificationProfileScheduleTable.TABLE_NAME)
|
||||
.where("${NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID} = ?", profileId)
|
||||
.run()
|
||||
.readToSingleLong()
|
||||
|
||||
updateProfile(
|
||||
NotificationProfile(
|
||||
id = profileId,
|
||||
name = profile.name,
|
||||
emoji = profile.emoji.orEmpty(),
|
||||
color = AvatarColor.fromColor(profile.color) ?: NotificationProfile.DEFAULT_NOTIFICATION_PROFILE_COLOR,
|
||||
createdAt = profile.createdAtMs,
|
||||
allowAllCalls = profile.allowAllCalls,
|
||||
allowAllMentions = profile.allowAllMentions,
|
||||
schedule = NotificationProfileSchedule(
|
||||
scheduleId,
|
||||
profile.scheduleEnabled,
|
||||
profile.scheduleStartTime,
|
||||
profile.scheduleEndTime,
|
||||
profile.scheduleDaysEnabled.map { it.toLocal() }.toSet()
|
||||
),
|
||||
allowedMembers = profile.allowedMembers.mapNotNull { remoteRecipient -> StorageSyncModels.remoteToLocalRecipient(remoteRecipient)?.id }.toSet(),
|
||||
notificationProfileId = notificationProfileId,
|
||||
deletedTimestampMs = profile.deletedAtTimestampMs,
|
||||
storageServiceId = StorageId.forNotificationProfile(notificationProfileRecord.id.raw),
|
||||
storageServiceProto = notificationProfileRecord.serializedUnknowns
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun getStorageSyncIdsMap(): Map<NotificationProfileId, StorageId> {
|
||||
return readableDatabase
|
||||
.select(NotificationProfileTable.NOTIFICATION_PROFILE_ID, NotificationProfileTable.STORAGE_SERVICE_ID)
|
||||
.from(NotificationProfileTable.TABLE_NAME)
|
||||
.where("${NotificationProfileTable.STORAGE_SERVICE_ID} IS NOT NULL")
|
||||
.run()
|
||||
.readToMap { cursor ->
|
||||
val id = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID))
|
||||
val encodedKey = cursor.requireNonNullString(NotificationProfileTable.STORAGE_SERVICE_ID)
|
||||
val key = Base64.decodeOrThrow(encodedKey)
|
||||
id to StorageId.forNotificationProfile(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStorageSyncIds(): List<StorageId> {
|
||||
return readableDatabase
|
||||
.select(NotificationProfileTable.STORAGE_SERVICE_ID)
|
||||
.from(NotificationProfileTable.TABLE_NAME)
|
||||
.where("${NotificationProfileTable.STORAGE_SERVICE_ID} IS NOT NULL")
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val encodedKey = cursor.requireNonNullString(NotificationProfileTable.STORAGE_SERVICE_ID)
|
||||
val key = Base64.decodeOrThrow(encodedKey)
|
||||
StorageId.forNotificationProfile(key)
|
||||
}.also { Log.i(TAG, "${it.size} profiles have storage ids.") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes storageIds from notification profiles that have been deleted for [DELETED_LIFESPAN].
|
||||
*/
|
||||
fun removeStorageIdsFromOldDeletedProfiles(now: Long): Int {
|
||||
return writableDatabase
|
||||
.update(NotificationProfileTable.TABLE_NAME)
|
||||
.values(NotificationProfileTable.STORAGE_SERVICE_ID to null)
|
||||
.where("${NotificationProfileTable.STORAGE_SERVICE_ID} NOT NULL AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} < ?", now - DELETED_LIFESPAN)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes storageIds of profiles that are local only and deleted
|
||||
*/
|
||||
fun removeStorageIdsFromLocalOnlyDeletedProfiles(storageIds: Collection<StorageId>): Int {
|
||||
var updated = 0
|
||||
|
||||
SqlUtil.buildCollectionQuery(NotificationProfileTable.STORAGE_SERVICE_ID, storageIds.map { Base64.encodeWithPadding(it.raw) }, "${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND")
|
||||
.forEach {
|
||||
updated += writableDatabase.update(
|
||||
NotificationProfileTable.TABLE_NAME,
|
||||
contentValuesOf(NotificationProfileTable.STORAGE_SERVICE_ID to null),
|
||||
it.where,
|
||||
it.whereArgs
|
||||
)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
|
||||
val count = writableDatabase
|
||||
.update(NotificationProfileAllowedMembersTable.TABLE_NAME)
|
||||
@@ -331,7 +549,10 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
allowAllMentions = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_MENTIONS),
|
||||
schedule = getProfileSchedule(profileId),
|
||||
allowedMembers = getProfileAllowedMembers(profileId),
|
||||
notificationProfileId = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID))
|
||||
notificationProfileId = NotificationProfileId.from(cursor.requireNonNullString(NotificationProfileTable.NOTIFICATION_PROFILE_ID)),
|
||||
deletedTimestampMs = cursor.requireLong(NotificationProfileTable.DELETED_TIMESTAMP_MS),
|
||||
storageServiceId = cursor.requireString(NotificationProfileTable.STORAGE_SERVICE_ID)?.let { StorageId.forNotificationProfile(Base64.decodeNullableOrThrow(it)) },
|
||||
storageServiceProto = Base64.decodeOrNull(cursor.requireString(NotificationProfileTable.STORAGE_SERVICE_PROTO))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -372,6 +593,24 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase
|
||||
return allowed
|
||||
}
|
||||
|
||||
private fun rotateStorageId(id: Long) {
|
||||
writableDatabase
|
||||
.update(NotificationProfileTable.TABLE_NAME)
|
||||
.values(NotificationProfileTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
|
||||
.where("${NotificationProfileTable.ID} = ?", id)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that there is no other notification profile with the same [name]
|
||||
*/
|
||||
private fun isDuplicateName(name: String, id: Long = -1): Boolean {
|
||||
return readableDatabase
|
||||
.exists(NotificationProfileTable.TABLE_NAME)
|
||||
.where("${NotificationProfileTable.NAME} = ? AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} = 0 AND ${NotificationProfileTable.ID} != ?", name, id)
|
||||
.run()
|
||||
}
|
||||
|
||||
sealed class NotificationProfileChangeResult {
|
||||
data class Success(val notificationProfile: NotificationProfile) : NotificationProfileChangeResult()
|
||||
object DuplicateName : NotificationProfileChangeResult()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.logging.logI
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
|
||||
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
|
||||
import org.thoughtcrime.securesms.database.NotificationProfileTables
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
@@ -110,6 +112,18 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
|
||||
inserts.addAll(newChatFolderInserts)
|
||||
allNewStorageIds.addAll(newChatFolderStorageIds.values)
|
||||
|
||||
val oldNotificationProfileStorageIds = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
|
||||
val newNotificationProfileStorageIds = generateNotificationProfileStorageIds(oldNotificationProfileStorageIds)
|
||||
val newNotificationProfileInserts: List<SignalStorageRecord> = oldNotificationProfileStorageIds.keys
|
||||
.mapNotNull {
|
||||
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", it)
|
||||
SignalDatabase.notificationProfiles.getProfile(query)
|
||||
}
|
||||
.map { record -> StorageSyncModels.localToRemoteRecord(record, newNotificationProfileStorageIds[record.notificationProfileId]!!.raw) }
|
||||
|
||||
inserts.addAll(newNotificationProfileInserts)
|
||||
allNewStorageIds.addAll(newNotificationProfileStorageIds.values)
|
||||
|
||||
val recordIkm: RecordIkm? = if (Recipient.self().storageServiceEncryptionV2Capability.isSupported) {
|
||||
Log.i(TAG, "Generating and including a new recordIkm.")
|
||||
RecordIkm.generate()
|
||||
@@ -151,6 +165,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
|
||||
SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds)
|
||||
SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id))
|
||||
SignalDatabase.chatFolders.applyStorageIdUpdates(newChatFolderStorageIds)
|
||||
SignalDatabase.notificationProfiles.applyStorageIdUpdates(newNotificationProfileStorageIds)
|
||||
SignalDatabase.unknownStorageIds.deleteAll()
|
||||
}
|
||||
|
||||
@@ -180,6 +195,12 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
|
||||
return out
|
||||
}
|
||||
|
||||
private fun generateNotificationProfileStorageIds(oldKeys: Map<NotificationProfileId, StorageId>): Map<NotificationProfileId, StorageId> {
|
||||
return oldKeys.mapValues { (_, value) ->
|
||||
value.withNewBytes(StorageSyncHelper.generateKey())
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<StorageForcePushJob?> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): StorageForcePushJob {
|
||||
return StorageForcePushJob(parameters)
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
|
||||
import org.thoughtcrime.securesms.database.NotificationProfileTables
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.storage.ChatFolderRecordProcessor
|
||||
import org.thoughtcrime.securesms.storage.ContactRecordProcessor
|
||||
import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor
|
||||
import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor
|
||||
import org.thoughtcrime.securesms.storage.NotificationProfileRecordProcessor
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncModels
|
||||
@@ -40,6 +42,7 @@ import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
|
||||
@@ -53,6 +56,7 @@ import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record
|
||||
import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record
|
||||
import org.whispersystems.signalservice.api.storage.toSignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord
|
||||
@@ -279,9 +283,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
if (idDifference.localOnlyIds.isNotEmpty()) {
|
||||
val updatedRecipients = SignalDatabase.recipients.removeStorageIdsFromLocalOnlyUnregisteredRecipients(idDifference.localOnlyIds)
|
||||
val updatedFolders = SignalDatabase.chatFolders.removeStorageIdsFromLocalOnlyDeletedFolders(idDifference.localOnlyIds)
|
||||
val updatedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromLocalOnlyDeletedProfiles(idDifference.localOnlyIds)
|
||||
|
||||
if (updatedRecipients > 0 || updatedFolders > 0) {
|
||||
Log.w(TAG, "Found $updatedRecipients recipients and $updatedFolders folders that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.")
|
||||
if (updatedRecipients > 0 || updatedFolders > 0 || updatedProfiles > 0) {
|
||||
Log.w(TAG, "Found $updatedRecipients recipients, $updatedFolders folders, $updatedProfiles notification profiles that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.")
|
||||
|
||||
localStorageIdsBeforeMerge = getAllLocalStorageIds(self)
|
||||
idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge)
|
||||
@@ -310,7 +315,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: ${remoteOnly.contacts.size}, GV1: ${remoteOnly.gv1.size}, GV2: ${remoteOnly.gv2.size}, Account: ${remoteOnly.account.size}, DLists: ${remoteOnly.storyDistributionLists.size}, call links: ${remoteOnly.callLinkRecords.size}, chat folders: ${remoteOnly.chatFolderRecords.size}")
|
||||
Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: ${remoteOnly.contacts.size}, GV1: ${remoteOnly.gv1.size}, GV2: ${remoteOnly.gv2.size}, Account: ${remoteOnly.account.size}, DLists: ${remoteOnly.storyDistributionLists.size}, call links: ${remoteOnly.callLinkRecords.size}, chat folders: ${remoteOnly.chatFolderRecords.size}, notification profiles: ${remoteOnly.notificationProfileRecords.size}")
|
||||
|
||||
processKnownRecords(context, remoteOnly)
|
||||
|
||||
@@ -352,13 +357,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
self = freshSelf()
|
||||
|
||||
val removedUnregistered = SignalDatabase.recipients.removeStorageIdsFromOldUnregisteredRecipients(System.currentTimeMillis())
|
||||
if (removedUnregistered > 0) {
|
||||
Log.i(TAG, "Removed $removedUnregistered recipients from storage service that have been unregistered for longer than 30 days.")
|
||||
}
|
||||
|
||||
val removedDeletedFolders = SignalDatabase.chatFolders.removeStorageIdsFromOldDeletedFolders(System.currentTimeMillis())
|
||||
if (removedDeletedFolders > 0) {
|
||||
Log.i(TAG, "Removed $removedDeletedFolders folders from storage service that have been deleted for longer than 30 days.")
|
||||
val removedDeletedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
|
||||
if (removedUnregistered > 0 || removedDeletedFolders > 0 || removedDeletedProfiles > 0) {
|
||||
Log.i(TAG, "Removed $removedUnregistered unregistered, $removedDeletedFolders folders, $removedDeletedProfiles notification profiles from storage service that have been deleted for longer than 30 days.")
|
||||
}
|
||||
|
||||
val localStorageIds = getAllLocalStorageIds(self)
|
||||
@@ -454,12 +456,14 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR)
|
||||
CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR)
|
||||
ChatFolderRecordProcessor().process(records.chatFolderRecords, StorageSyncHelper.KEY_GENERATOR)
|
||||
NotificationProfileRecordProcessor().process(records.notificationProfileRecords, StorageSyncHelper.KEY_GENERATOR)
|
||||
}
|
||||
|
||||
private fun getAllLocalStorageIds(self: Recipient): List<StorageId> {
|
||||
return SignalDatabase.recipients.getContactStorageSyncIds() +
|
||||
listOf(StorageId.forAccount(self.storageId)) +
|
||||
SignalDatabase.chatFolders.getStorageSyncIds() +
|
||||
SignalDatabase.notificationProfiles.getStorageSyncIds() +
|
||||
SignalDatabase.unknownStorageIds.allUnknownIds
|
||||
}
|
||||
|
||||
@@ -533,6 +537,16 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
}
|
||||
}
|
||||
|
||||
ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> {
|
||||
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.STORAGE_SERVICE_ID} = ?", Base64.encodeWithPadding(id.raw))
|
||||
val notificationProfile = SignalDatabase.notificationProfiles.getProfile(query)
|
||||
if (notificationProfile?.notificationProfileId != null) {
|
||||
records.add(StorageSyncModels.localToRemoteRecord(notificationProfile, id.raw))
|
||||
} else {
|
||||
throw MissingNotificationProfileModelError("Missing local notification profile model! Type: " + id.type)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val unknown = SignalDatabase.unknownStorageIds.getById(id.raw)
|
||||
if (unknown != null) {
|
||||
@@ -567,6 +581,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
val storyDistributionLists: MutableList<SignalStoryDistributionListRecord> = mutableListOf()
|
||||
val callLinkRecords: MutableList<SignalCallLinkRecord> = mutableListOf()
|
||||
val chatFolderRecords: MutableList<SignalChatFolderRecord> = mutableListOf()
|
||||
val notificationProfileRecords: MutableList<SignalNotificationProfileRecord> = mutableListOf()
|
||||
|
||||
init {
|
||||
for (record in records) {
|
||||
@@ -584,6 +599,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id)
|
||||
} else if (record.proto.chatFolder != null) {
|
||||
chatFolderRecords += record.proto.chatFolder!!.toSignalChatFolderRecord(record.id)
|
||||
} else if (record.proto.notificationProfile != null) {
|
||||
notificationProfileRecords += record.proto.notificationProfile!!.toSignalNotificationProfileRecord(record.id)
|
||||
} else if (record.id.isUnknown) {
|
||||
unknown += record
|
||||
} else {
|
||||
@@ -599,6 +616,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
|
||||
private class MissingChatFolderModelError(message: String?) : Error(message)
|
||||
|
||||
private class MissingNotificationProfileModelError(message: String?) : Error(message)
|
||||
|
||||
private class MissingUnknownModelError(message: String?) : Error(message)
|
||||
|
||||
class Factory : Job.Factory<StorageSyncJob?> {
|
||||
|
||||
@@ -2,20 +2,28 @@ package org.thoughtcrime.securesms.notifications.profiles
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
|
||||
data class NotificationProfile(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val color: AvatarColor = AvatarColor.A210,
|
||||
val color: AvatarColor = DEFAULT_NOTIFICATION_PROFILE_COLOR,
|
||||
val createdAt: Long,
|
||||
val allowAllCalls: Boolean = true,
|
||||
val allowAllMentions: Boolean = false,
|
||||
val schedule: NotificationProfileSchedule,
|
||||
val allowedMembers: Set<RecipientId> = emptySet(),
|
||||
val notificationProfileId: NotificationProfileId
|
||||
val notificationProfileId: NotificationProfileId,
|
||||
val deletedTimestampMs: Long = 0,
|
||||
val storageServiceId: StorageId? = null,
|
||||
val storageServiceProto: ByteArray? = null
|
||||
) : Comparable<NotificationProfile> {
|
||||
|
||||
companion object {
|
||||
val DEFAULT_NOTIFICATION_PROFILE_COLOR = AvatarColor.A210
|
||||
}
|
||||
|
||||
fun isRecipientAllowed(id: RecipientId): Boolean {
|
||||
return allowedMembers.contains(id)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.Recipient
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -96,7 +97,7 @@ class ChatFolderRecordProcessor : DefaultStorageRecordProcessor<SignalChatFolder
|
||||
SignalDatabase.chatFolders.updateChatFolderFromStorageSync(update.new)
|
||||
}
|
||||
|
||||
private fun containsInvalidServiceId(recipients: List<ChatFolderRecord.Recipient>): Boolean {
|
||||
private fun containsInvalidServiceId(recipients: List<Recipient>): Boolean {
|
||||
return recipients.any { recipient ->
|
||||
recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.storage
|
||||
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.NotificationProfileTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.storage.protos.Recipient
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Record processor for [SignalNotificationProfileRecord].
|
||||
* Handles merging and updating our local store when processing remote notification profile storage records.
|
||||
*/
|
||||
class NotificationProfileRecordProcessor : DefaultStorageRecordProcessor<SignalNotificationProfileRecord>() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(NotificationProfileRecordProcessor::class)
|
||||
}
|
||||
|
||||
override fun compare(o1: SignalNotificationProfileRecord, o2: SignalNotificationProfileRecord): Int {
|
||||
return if (o1.proto.id == o2.proto.id) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification profiles must have a valid identifier
|
||||
* Notification profiles must have a name
|
||||
* All allowed members must have a valid serviceId
|
||||
*/
|
||||
override fun isInvalid(remote: SignalNotificationProfileRecord): Boolean {
|
||||
return UuidUtil.parseOrNull(remote.proto.id) == null ||
|
||||
remote.proto.name.isEmpty() ||
|
||||
containsInvalidServiceId(remote.proto.allowedMembers)
|
||||
}
|
||||
|
||||
override fun getMatching(remote: SignalNotificationProfileRecord, keyGenerator: StorageKeyGenerator): Optional<SignalNotificationProfileRecord> {
|
||||
Log.d(TAG, "Attempting to get matching record...")
|
||||
val uuid: UUID = UuidUtil.parseOrThrow(remote.proto.id)
|
||||
val query = SqlUtil.buildQuery("${NotificationProfileTables.NotificationProfileTable.NOTIFICATION_PROFILE_ID} = ?", NotificationProfileId(uuid))
|
||||
|
||||
val notificationProfile = SignalDatabase.notificationProfiles.getProfile(query)
|
||||
|
||||
return if (notificationProfile?.storageServiceId != null) {
|
||||
StorageSyncModels.localToRemoteNotificationProfile(notificationProfile, notificationProfile.storageServiceId.raw).asOptional()
|
||||
} else if (notificationProfile != null) {
|
||||
Log.d(TAG, "Notification profile was missing a storage service id, generating one")
|
||||
val storageId = StorageId.forNotificationProfile(keyGenerator.generate())
|
||||
SignalDatabase.notificationProfiles.applyStorageIdUpdate(notificationProfile.notificationProfileId, storageId)
|
||||
StorageSyncModels.localToRemoteNotificationProfile(notificationProfile, storageId.raw).asOptional()
|
||||
} else {
|
||||
Log.d(TAG, "Could not find a matching record. Returning an empty.")
|
||||
Optional.empty<SignalNotificationProfileRecord>()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted record takes precedence over a non-deleted record
|
||||
* while an earlier deletion takes precedence over a later deletion
|
||||
*/
|
||||
override fun merge(remote: SignalNotificationProfileRecord, local: SignalNotificationProfileRecord, keyGenerator: StorageKeyGenerator): SignalNotificationProfileRecord {
|
||||
val isRemoteDeleted = remote.proto.deletedAtTimestampMs > 0
|
||||
val isLocalDeleted = local.proto.deletedAtTimestampMs > 0
|
||||
|
||||
return when {
|
||||
isRemoteDeleted && isLocalDeleted -> if (remote.proto.deletedAtTimestampMs < local.proto.deletedAtTimestampMs) remote else local
|
||||
isRemoteDeleted -> remote
|
||||
isLocalDeleted -> local
|
||||
else -> remote
|
||||
}
|
||||
}
|
||||
|
||||
override fun insertLocal(record: SignalNotificationProfileRecord) {
|
||||
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(record)
|
||||
}
|
||||
|
||||
override fun updateLocal(update: StorageRecordUpdate<SignalNotificationProfileRecord>) {
|
||||
SignalDatabase.notificationProfiles.updateNotificationProfileFromStorageSync(update.new)
|
||||
}
|
||||
|
||||
private fun containsInvalidServiceId(recipients: List<Recipient>): Boolean {
|
||||
return recipients.any { recipient ->
|
||||
recipient.contact != null && ServiceId.parseOrNull(recipient.contact!!.serviceId) == null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.isNotEmpty
|
||||
import org.signal.core.util.isNullOrEmpty
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
@@ -20,14 +22,21 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.inAppPayment
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.RecipientRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
@@ -36,6 +45,7 @@ import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record
|
||||
import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record
|
||||
import org.whispersystems.signalservice.api.storage.toSignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
@@ -44,13 +54,18 @@ import org.whispersystems.signalservice.internal.storage.protos.AccountRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState
|
||||
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record
|
||||
import java.time.DayOfWeek
|
||||
import java.util.Currency
|
||||
import kotlin.math.max
|
||||
import org.whispersystems.signalservice.internal.storage.protos.AvatarColor as RemoteAvatarColor
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolder
|
||||
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek as RemoteDayOfWeek
|
||||
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
|
||||
|
||||
object StorageSyncModels {
|
||||
|
||||
private val TAG = Log.tag(StorageSyncModels::class.java)
|
||||
|
||||
fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord {
|
||||
if (settings.storageId == null) {
|
||||
throw AssertionError("Must have a storage key!")
|
||||
@@ -82,6 +97,10 @@ object StorageSyncModels {
|
||||
return localToRemoteChatFolder(folder, rawStorageId).toSignalStorageRecord()
|
||||
}
|
||||
|
||||
fun localToRemoteRecord(profile: NotificationProfile, rawStorageId: ByteArray): SignalStorageRecord {
|
||||
return localToRemoteNotificationProfile(profile, rawStorageId).toSignalStorageRecord()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode {
|
||||
return when (phoneNumberPhoneNumberSharingMode) {
|
||||
@@ -396,22 +415,96 @@ object StorageSyncModels {
|
||||
}.build().toSignalChatFolderRecord(StorageId.forChatFolder(rawStorageId))
|
||||
}
|
||||
|
||||
private fun localToRemoteChatFolderRecipients(threadIds: List<Long>): List<RemoteChatFolder.Recipient> {
|
||||
fun localToRemoteNotificationProfile(profile: NotificationProfile, rawStorageId: ByteArray?): SignalNotificationProfileRecord {
|
||||
return SignalNotificationProfileRecord.newBuilder(profile.storageServiceProto).apply {
|
||||
id = UuidUtil.toByteArray(profile.notificationProfileId.uuid).toByteString()
|
||||
name = profile.name
|
||||
emoji = profile.emoji
|
||||
color = profile.color.colorInt()
|
||||
createdAtMs = profile.createdAt
|
||||
allowAllCalls = profile.allowAllCalls
|
||||
allowAllMentions = profile.allowAllMentions
|
||||
allowedMembers = localToRemoteRecipients(profile.allowedMembers.toList())
|
||||
scheduleEnabled = profile.schedule.enabled
|
||||
scheduleStartTime = profile.schedule.start
|
||||
scheduleEndTime = profile.schedule.end
|
||||
scheduleDaysEnabled = localToRemoteDayOfWeek(profile.schedule.daysEnabled)
|
||||
deletedAtTimestampMs = profile.deletedTimestampMs
|
||||
}.build().toSignalNotificationProfileRecord(StorageId.forNotificationProfile(rawStorageId))
|
||||
}
|
||||
|
||||
private fun localToRemoteDayOfWeek(daysEnabled: Set<DayOfWeek>): List<RemoteDayOfWeek> {
|
||||
return daysEnabled.map { day ->
|
||||
when (day) {
|
||||
DayOfWeek.MONDAY -> RemoteDayOfWeek.MONDAY
|
||||
DayOfWeek.TUESDAY -> RemoteDayOfWeek.TUESDAY
|
||||
DayOfWeek.WEDNESDAY -> RemoteDayOfWeek.WEDNESDAY
|
||||
DayOfWeek.THURSDAY -> RemoteDayOfWeek.THURSDAY
|
||||
DayOfWeek.FRIDAY -> RemoteDayOfWeek.FRIDAY
|
||||
DayOfWeek.SATURDAY -> RemoteDayOfWeek.SATURDAY
|
||||
DayOfWeek.SUNDAY -> RemoteDayOfWeek.SUNDAY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun RemoteDayOfWeek.toLocal(): DayOfWeek {
|
||||
return when (this) {
|
||||
RemoteDayOfWeek.UNKNOWN -> DayOfWeek.MONDAY
|
||||
RemoteDayOfWeek.MONDAY -> DayOfWeek.MONDAY
|
||||
RemoteDayOfWeek.TUESDAY -> DayOfWeek.TUESDAY
|
||||
RemoteDayOfWeek.WEDNESDAY -> DayOfWeek.WEDNESDAY
|
||||
RemoteDayOfWeek.THURSDAY -> DayOfWeek.THURSDAY
|
||||
RemoteDayOfWeek.FRIDAY -> DayOfWeek.FRIDAY
|
||||
RemoteDayOfWeek.SATURDAY -> DayOfWeek.SATURDAY
|
||||
RemoteDayOfWeek.SUNDAY -> DayOfWeek.SUNDAY
|
||||
}
|
||||
}
|
||||
|
||||
private fun localToRemoteChatFolderRecipients(threadIds: List<Long>): List<RemoteRecipient> {
|
||||
val recipientIds = SignalDatabase.threads.getRecipientIdsForThreadIds(threadIds)
|
||||
return localToRemoteRecipients(recipientIds)
|
||||
}
|
||||
|
||||
private fun localToRemoteRecipients(recipientIds: List<RecipientId>): List<RemoteRecipient> {
|
||||
return recipientIds.mapNotNull { id ->
|
||||
val recipient = SignalDatabase.recipients.getRecordForSync(id) ?: throw AssertionError("Missing recipient for id")
|
||||
when (recipient.recipientType) {
|
||||
RecipientType.INDIVIDUAL -> {
|
||||
RemoteChatFolder.Recipient(contact = RemoteChatFolder.Recipient.Contact(serviceId = recipient.serviceId?.toString() ?: "", e164 = recipient.e164 ?: ""))
|
||||
RemoteRecipient(contact = RemoteRecipient.Contact(serviceId = recipient.serviceId?.toString() ?: "", e164 = recipient.e164 ?: ""))
|
||||
}
|
||||
RecipientType.GV1 -> {
|
||||
RemoteChatFolder.Recipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString())
|
||||
RemoteRecipient(legacyGroupId = recipient.groupId!!.requireV1().decodedId.toByteString())
|
||||
}
|
||||
RecipientType.GV2 -> {
|
||||
RemoteChatFolder.Recipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString())
|
||||
RemoteRecipient(groupMasterKey = recipient.syncExtras.groupMasterKey!!.serialize().toByteString())
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun remoteToLocalRecipient(remoteRecipient: RemoteRecipient): Recipient? {
|
||||
return if (remoteRecipient.contact != null) {
|
||||
val serviceId = ServiceId.parseOrNull(remoteRecipient.contact!!.serviceId)
|
||||
val e164 = remoteRecipient.contact!!.e164
|
||||
Recipient.externalPush(SignalServiceAddress(serviceId, e164))
|
||||
} else if (remoteRecipient.legacyGroupId != null) {
|
||||
try {
|
||||
Recipient.externalGroupExact(GroupId.v1(remoteRecipient.legacyGroupId!!.toByteArray()))
|
||||
} catch (e: BadGroupIdException) {
|
||||
Log.w(TAG, "Failed to parse groupV1 ID!", e)
|
||||
null
|
||||
}
|
||||
} else if (remoteRecipient.groupMasterKey != null) {
|
||||
try {
|
||||
Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(remoteRecipient.groupMasterKey!!.toByteArray())))
|
||||
} catch (e: InvalidInputException) {
|
||||
Log.w(TAG, "Failed to parse groupV2 master key!", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not find recipient")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
@@ -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 += ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.whispersystems.signalservice.api.storage
|
||||
|
||||
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Wrapper around a [NotificationProfile] to pair it with a [StorageId].
|
||||
*/
|
||||
data class SignalNotificationProfileRecord(
|
||||
override val id: StorageId,
|
||||
override val proto: NotificationProfile
|
||||
) : SignalRecord<NotificationProfile> {
|
||||
|
||||
companion object {
|
||||
fun newBuilder(serializedUnknowns: ByteArray?): NotificationProfile.Builder {
|
||||
return serializedUnknowns?.let { builderFromUnknowns(it) } ?: NotificationProfile.Builder()
|
||||
}
|
||||
|
||||
private fun builderFromUnknowns(serializedUnknowns: ByteArray): NotificationProfile.Builder {
|
||||
return try {
|
||||
NotificationProfile.ADAPTER.decode(serializedUnknowns).newBuilder()
|
||||
} catch (e: IOException) {
|
||||
NotificationProfile.Builder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ message ManifestRecord {
|
||||
STORY_DISTRIBUTION_LIST = 5;
|
||||
CALL_LINK = 7;
|
||||
CHAT_FOLDER = 8;
|
||||
NOTIFICATION_PROFILE = 9;
|
||||
}
|
||||
|
||||
bytes raw = 1;
|
||||
@@ -74,6 +75,7 @@ message StorageRecord {
|
||||
StoryDistributionListRecord storyDistributionList = 5;
|
||||
CallLinkRecord callLink = 7;
|
||||
ChatFolderRecord chatFolder = 8;
|
||||
NotificationProfile notificationProfile = 9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +227,26 @@ message AccountRecord {
|
||||
}
|
||||
}
|
||||
|
||||
message BackupTierHistory {
|
||||
// See zkgroup for integer particular values. Unset if backups are not enabled.
|
||||
optional uint64 backupTier = 1;
|
||||
optional uint64 endedAtTimestamp = 2;
|
||||
}
|
||||
|
||||
message NotificationProfileManualOverride {
|
||||
message ManuallyEnabled {
|
||||
bytes id = 1;
|
||||
|
||||
// This will be unset if no timespan was chosen in the UI.
|
||||
uint64 endAtTimestampMs = 3;
|
||||
}
|
||||
|
||||
oneof override {
|
||||
uint64 disabledAtTimestampMs = 1;
|
||||
ManuallyEnabled enabled = 2;
|
||||
}
|
||||
}
|
||||
|
||||
bytes profileKey = 1;
|
||||
string givenName = 2;
|
||||
string familyName = 3;
|
||||
@@ -243,7 +265,7 @@ message AccountRecord {
|
||||
Payments payments = 16;
|
||||
uint32 universalExpireTimer = 17;
|
||||
bool primarySendsSms = 18;
|
||||
string e164 = 19;
|
||||
reserved /* e164 */ 19;
|
||||
repeated string preferredReactionEmoji = 20;
|
||||
bytes subscriberId = 21;
|
||||
string subscriberCurrencyCode = 22;
|
||||
@@ -264,9 +286,11 @@ message AccountRecord {
|
||||
reserved /* backupsSubscriberCurrencyCode */ 37;
|
||||
reserved /* backupsSubscriptionManuallyCancelled */ 38;
|
||||
optional bool hasBackup = 39; // Set to true after backups are enabled and one is uploaded.
|
||||
optional uint64 backupTier = 40; // See zkgroup for integer particular values
|
||||
optional uint64 backupTier = 40; // See zkgroup for integer particular values. Unset if backups are not enabled.
|
||||
IAPSubscriberData backupSubscriberData = 41;
|
||||
optional AvatarColor avatarColor = 42;
|
||||
BackupTierHistory backupTierHistory = 43;
|
||||
NotificationProfileManualOverride notificationProfileManualOverride = 44;
|
||||
}
|
||||
|
||||
message StoryDistributionListRecord {
|
||||
@@ -284,8 +308,7 @@ message CallLinkRecord {
|
||||
uint64 deletedAtTimestampMs = 3;
|
||||
}
|
||||
|
||||
message ChatFolderRecord {
|
||||
message Recipient {
|
||||
message Recipient {
|
||||
message Contact {
|
||||
string serviceId = 1;
|
||||
string e164 = 2;
|
||||
@@ -296,8 +319,9 @@ message ChatFolderRecord {
|
||||
bytes legacyGroupId = 2;
|
||||
bytes groupMasterKey = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message ChatFolderRecord {
|
||||
// Represents the default "All chats" folder record vs all other custom folders
|
||||
enum FolderType {
|
||||
UNKNOWN = 0;
|
||||
@@ -317,3 +341,30 @@ message ChatFolderRecord {
|
||||
repeated Recipient excludedRecipients = 10;
|
||||
uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and includedRecipients should be empty
|
||||
}
|
||||
|
||||
message NotificationProfile {
|
||||
enum DayOfWeek {
|
||||
UNKNOWN = 0; // Interpret as "Monday"
|
||||
MONDAY = 1;
|
||||
TUESDAY = 2;
|
||||
WEDNESDAY = 3;
|
||||
THURSDAY = 4;
|
||||
FRIDAY = 5;
|
||||
SATURDAY = 6;
|
||||
SUNDAY = 7;
|
||||
}
|
||||
|
||||
bytes id = 1;
|
||||
string name = 2;
|
||||
optional string emoji = 3;
|
||||
fixed32 color = 4; // 0xAARRGGBB
|
||||
uint64 createdAtMs = 5;
|
||||
bool allowAllCalls = 6;
|
||||
bool allowAllMentions = 7;
|
||||
repeated Recipient allowedMembers = 8;
|
||||
bool scheduleEnabled = 9;
|
||||
uint32 scheduleStartTime = 10; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345)
|
||||
uint32 scheduleEndTime = 11; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345)
|
||||
repeated DayOfWeek scheduleDaysEnabled = 12;
|
||||
uint64 deletedAtTimestampMs = 13;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user