From 8e40310f4c4fc107b000afc1806d838d09f4c549 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:33:46 -0400 Subject: [PATCH] Sync notification profiles via storage sync Notification profiles are stored as their own record type in the storage service. Until now they were treated as unknown records, which also meant they were dropped from the manifest on every force push. Store them locally in a new notification_profile table and handle them like the other synced record types (sticker packs in particular), so they survive force pushes and can be read locally. --- .../signal/manager/helper/StorageHelper.java | 53 +++- .../manager/storage/AccountDatabase.java | 19 +- .../signal/manager/storage/SignalAccount.java | 7 + .../NotificationProfile.java | 17 ++ .../NotificationProfileStore.java | 244 ++++++++++++++++++ .../NotificationProfileRecordProcessor.java | 79 ++++++ .../syncStorage/StorageSyncModels.java | 11 + .../NotificationProfileStoreTest.java | 117 +++++++++ .../StorageRecordProcessorTest.java | 29 +++ .../signal-cli/reachability-metadata.json | 116 +++++++++ 10 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java index 8438cdba..d3236098 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java @@ -7,12 +7,14 @@ import org.asamk.signal.manager.api.Profile; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile; import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.stickers.StickerPack; import org.asamk.signal.manager.syncStorage.AccountRecordProcessor; import org.asamk.signal.manager.syncStorage.ContactRecordProcessor; import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor; import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor; +import org.asamk.signal.manager.syncStorage.NotificationProfileRecordProcessor; import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor; import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector; import org.asamk.signal.manager.syncStorage.StorageSyncModels; @@ -58,7 +60,8 @@ public class StorageHelper { ManifestRecord.Identifier.Type.GROUPV1.getValue(), ManifestRecord.Identifier.Type.GROUPV2.getValue(), ManifestRecord.Identifier.Type.ACCOUNT.getValue(), - ManifestRecord.Identifier.Type.STICKER_PACK.getValue()); + ManifestRecord.Identifier.Type.STICKER_PACK.getValue(), + ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue()); private final SignalAccount account; private final SignalDependencies dependencies; @@ -134,6 +137,7 @@ public class StorageHelper { account.getRecipientStore().setMissingStorageIds(); account.getGroupStore().setMissingStorageIds(); account.getStickerStore().setMissingStorageIds(); + account.getNotificationProfileStore().setMissingStorageIds(); var needsMultiDeviceSync = false; @@ -245,12 +249,15 @@ public class StorageHelper { oldUnregisteredLocalOnlyIds); final var updatedStickers = account.getStickerStore() .removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds); + final var updatedNotificationProfiles = account.getNotificationProfileStore() + .removeLocalOnlyDeletedNotificationProfiles(connection, oldUnregisteredLocalOnlyIds); - if (updated > 0 || updatedStickers > 0) { + if (updated > 0 || updatedStickers > 0 || updatedNotificationProfiles > 0) { logger.warn( - "Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.", + "Found {} recipients, {} sticker packs and {} notification profiles that were deleted remotely but only marked deleted locally. Removed those from local store.", updated, - updatedStickers); + updatedStickers, + updatedNotificationProfiles); } } @@ -436,6 +443,7 @@ public class StorageHelper { final Map newGroupV1StorageIds; final Map newGroupV2StorageIds; final Map newStickerPackStorageIds; + final Map newNotificationProfileStorageIds; try (final var connection = account.getAccountDatabase().getConnection()) { connection.setAutoCommit(false); @@ -495,6 +503,16 @@ public class StorageHelper { new StorageRecord.Builder().stickerPack(record).build())); } + final var notificationProfiles = account.getNotificationProfileStore() + .getNotificationProfiles(connection); + newNotificationProfileStorageIds = generateNotificationProfileStorageIds(notificationProfiles); + for (final var notificationProfile : notificationProfiles) { + final var storageId = newNotificationProfileStorageIds.get(notificationProfile.internalId()); + final var record = StorageSyncModels.localToRemoteRecord(notificationProfile); + newStorageRecords.add(new SignalStorageRecord(storageId, + new StorageRecord.Builder().notificationProfile(record).build())); + } + connection.commit(); } catch (SQLException e) { throw new RuntimeException("Failed to sync remote storage", e); @@ -546,6 +564,7 @@ public class StorageHelper { account.getRecipientStore().updateStorageIds(connection, newContactStorageIds); account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds); account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds); + account.getNotificationProfileStore().updateStorageIds(connection, newNotificationProfileStorageIds); // delete all unknown storage ids account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection); @@ -586,6 +605,14 @@ public class StorageHelper { _ -> StorageId.forStickerPack(KeyUtils.createRawStorageId()))); } + private Map generateNotificationProfileStorageIds( + final List notificationProfiles + ) { + return notificationProfiles.stream() + .collect(Collectors.toMap(NotificationProfile::internalId, + _ -> StorageId.forNotificationProfile(KeyUtils.createRawStorageId()))); + } + private void storeManifestLocally( final SignalStorageManifest remoteManifest ) { @@ -626,6 +653,7 @@ public class StorageHelper { storageIds.addAll(account.getGroupStore().getStorageIds(connection)); storageIds.addAll(account.getRecipientStore().getStorageIds(connection)); storageIds.addAll(account.getStickerStore().getStorageIds(connection)); + storageIds.addAll(account.getNotificationProfileStore().getStorageIds(connection)); storageIds.add(account.getRecipientStore().getSelfStorageId(connection)); return storageIds; } @@ -682,6 +710,16 @@ public class StorageHelper { final var record = StorageSyncModels.localToRemoteRecord(stickerPack); yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build()); } + case ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> { + final var notificationProfile = account.getNotificationProfileStore() + .getNotificationProfile(connection, storageId); + if (notificationProfile == null) { + throw new AssertionError("Missing local notification profile model for storage id: " + storageId); + } + final var record = StorageSyncModels.localToRemoteRecord(notificationProfile); + yield new SignalStorageRecord(storageId, + new StorageRecord.Builder().notificationProfile(record).build()); + } case null, default -> { throw new AssertionError("Got unknown local storage record type: " + storageId); } @@ -752,6 +790,7 @@ public class StorageHelper { context.getJobExecutor(), identityConflictsPendingRepair); final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection); + final var notificationProfileRecordProcessor = new NotificationProfileRecordProcessor(account, connection); final var contactRecords = records.stream() .filter(record -> record.getProto().contact != null) @@ -781,6 +820,11 @@ public class StorageHelper { logger.debug("Reading record {} of type stickerPack", record.getId()); stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack, record.getId())); + } else if (record.getProto().notificationProfile != null) { + logger.debug("Reading record {} of type notificationProfile", record.getId()); + notificationProfileRecordProcessor.process(StorageRecordConvertersKt.toSignalNotificationProfileRecord( + record.getProto().notificationProfile, + record.getId())); } else { unknownRecords.add(record.getId()); } @@ -790,6 +834,7 @@ public class StorageHelper { processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds()); + processedRecords.addAll(notificationProfileRecordProcessor.getUpdatedStorageIds()); return new Pair<>(unknownRecords, processedRecords); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java index 105422ab..13a0d685 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java @@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.storage.groups.GroupStore; import org.asamk.signal.manager.storage.identities.IdentityKeyStore; import org.asamk.signal.manager.storage.keyValue.KeyValueStore; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfileStore; import org.asamk.signal.manager.storage.prekeys.KyberPreKeyStore; import org.asamk.signal.manager.storage.prekeys.PreKeyStore; import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore; @@ -33,7 +34,7 @@ import java.util.UUID; public class AccountDatabase extends Database { private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class); - private static final long DATABASE_VERSION = 31; + private static final long DATABASE_VERSION = 32; private AccountDatabase(final HikariDataSource dataSource) { super(logger, DATABASE_VERSION, dataSource); @@ -59,6 +60,7 @@ public class AccountDatabase extends Database { KeyValueStore.createSql(connection); CdsiStore.createSql(connection); UnknownStorageIdStore.createSql(connection); + NotificationProfileStore.createSql(connection); } @Override @@ -653,6 +655,21 @@ public class AccountDatabase extends Database { """); } } + if (oldVersion < 32) { + logger.debug("Updating database: Creating notification profile table"); + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE notification_profile ( + _id INTEGER PRIMARY KEY, + profile_id BLOB UNIQUE NOT NULL, + name TEXT NOT NULL, + deleted_timestamp INTEGER NOT NULL DEFAULT 0, + storage_id BLOB UNIQUE, + storage_record BLOB + ) STRICT; + """); + } + } } private static void createUuidMappingTable( diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 77511d6e..4c41c531 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -49,6 +49,7 @@ import org.asamk.signal.manager.storage.senderKeys.SenderKeyStore; import org.asamk.signal.manager.storage.sessions.LegacySessionStore; import org.asamk.signal.manager.storage.sessions.SessionStore; import org.asamk.signal.manager.storage.stickers.LegacyStickerStore; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfileStore; import org.asamk.signal.manager.storage.stickers.StickerStore; import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore; import org.asamk.signal.manager.util.IOUtils; @@ -181,6 +182,7 @@ public class SignalAccount implements Closeable { private GroupStore groupStore; private RecipientStore recipientStore; private StickerStore stickerStore; + private NotificationProfileStore notificationProfileStore; private UnknownStorageIdStore unknownStorageIdStore; private ConfigurationStore configurationStore; private KeyValueStore keyValueStore; @@ -1303,6 +1305,11 @@ public class SignalAccount implements Closeable { return getOrCreate(() -> stickerStore, () -> stickerStore = new StickerStore(getAccountDatabase())); } + public NotificationProfileStore getNotificationProfileStore() { + return getOrCreate(() -> notificationProfileStore, + () -> notificationProfileStore = new NotificationProfileStore(getAccountDatabase())); + } + public SenderKeyStore getSenderKeyStore() { return getOrCreate(() -> senderKeyStore, () -> senderKeyStore = new SenderKeyStore(getAccountDatabase())); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java new file mode 100644 index 00000000..93e45ec3 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java @@ -0,0 +1,17 @@ +package org.asamk.signal.manager.storage.notificationProfiles; + +import org.whispersystems.signalservice.api.storage.StorageId; + +/** + * Local copy of a notification profile as synced via storage service. + * The full profile data is kept in the raw storage record, the remaining fields are + * denormalized for querying. + */ +public record NotificationProfile( + long internalId, + byte[] profileId, + String name, + long deletedTimestamp, + StorageId storageId, + byte[] storageRecord +) {} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java new file mode 100644 index 00000000..b3ab1cec --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java @@ -0,0 +1,244 @@ +package org.asamk.signal.manager.storage.notificationProfiles; + +import org.asamk.signal.manager.storage.Database; +import org.asamk.signal.manager.storage.Utils; +import org.asamk.signal.manager.util.KeyUtils; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class NotificationProfileStore { + + private static final String TABLE_NOTIFICATION_PROFILE = "notification_profile"; + + private final Database database; + + public static void createSql(Connection connection) throws SQLException { + // When modifying the CREATE statement here, also add a migration in AccountDatabase.java + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE notification_profile ( + _id INTEGER PRIMARY KEY, + profile_id BLOB UNIQUE NOT NULL, + name TEXT NOT NULL, + deleted_timestamp INTEGER NOT NULL DEFAULT 0, + storage_id BLOB UNIQUE, + storage_record BLOB + ) STRICT; + """); + } + } + + public NotificationProfileStore(final Database database) { + this.database = database; + } + + public List getNotificationProfiles() { + try (final var connection = database.getConnection()) { + return getNotificationProfiles(connection); + } catch (SQLException e) { + throw new RuntimeException("Failed read from notification profile store", e); + } + } + + public List getNotificationProfiles(final Connection connection) throws SQLException { + final var sql = ( + """ + SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record + FROM %s n + ORDER BY n._id + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQueryForStream(statement, this::getNotificationProfileFromResultSet).toList(); + } + } + + public NotificationProfile getNotificationProfile(final Connection connection, final byte[] profileId) throws SQLException { + final var sql = ( + """ + SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record + FROM %s n + WHERE n.profile_id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, profileId); + return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null); + } + } + + public NotificationProfile getNotificationProfile(final Connection connection, final StorageId storageId) throws SQLException { + final var sql = ( + """ + SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record + FROM %s n + WHERE n.storage_id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, storageId.getRaw()); + return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null); + } + } + + public List getStorageIds(final Connection connection) throws SQLException { + final var sql = ( + """ + SELECT n.storage_id + FROM %s n + WHERE n.storage_id IS NOT NULL + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList(); + } + } + + public void updateStorageId( + final Connection connection, + final byte[] profileId, + final StorageId storageId + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE profile_id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, storageId.getRaw()); + statement.setBytes(2, profileId); + statement.executeUpdate(); + } + } + + public void updateStorageIds( + final Connection connection, + final Map storageIdMap + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE _id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + for (final var entry : storageIdMap.entrySet()) { + statement.setBytes(1, entry.getValue().getRaw()); + statement.setLong(2, entry.getKey()); + statement.executeUpdate(); + } + } + } + + public void setMissingStorageIds() { + final var selectSql = ( + """ + SELECT n._id + FROM %s n + WHERE n.storage_id IS NULL + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + final var updateSql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE _id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + + try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + try (final var selectStatement = connection.prepareStatement(selectSql)) { + final var ids = Utils.executeQueryForStream(selectStatement, Utils::getIdMapper).toList(); + try (final var updateStatement = connection.prepareStatement(updateSql)) { + for (final var id : ids) { + updateStatement.setBytes(1, KeyUtils.createRawStorageId()); + updateStatement.setLong(2, id); + updateStatement.executeUpdate(); + } + } + } + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update notification profile store", e); + } + } + + /** + * Remove profiles that were deleted remotely and are only kept locally as deletion tombstones. + */ + public int removeLocalOnlyDeletedNotificationProfiles( + final Connection connection, + final Collection storageIds + ) throws SQLException { + final var sql = ( + """ + DELETE FROM %s + WHERE storage_id = ? AND deleted_timestamp > 0 + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + var count = 0; + try (final var statement = connection.prepareStatement(sql)) { + for (final var storageId : storageIds) { + statement.setBytes(1, storageId.getRaw()); + count += statement.executeUpdate(); + } + } + return count; + } + + public void upsertFromStorageSync( + final Connection connection, + final SignalNotificationProfileRecord record + ) throws SQLException { + final var remote = record.getProto(); + final var profileId = remote.id.toByteArray(); + final var storageRecord = remote.encode(); + + final var sql = ( + """ + INSERT INTO %s (profile_id, name, deleted_timestamp, storage_id, storage_record) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (profile_id) DO UPDATE SET + name = excluded.name, + deleted_timestamp = excluded.deleted_timestamp, + storage_id = excluded.storage_id, + storage_record = excluded.storage_record + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, profileId); + statement.setString(2, remote.name); + statement.setLong(3, remote.deletedAtTimestampMs); + statement.setBytes(4, record.getId().getRaw()); + statement.setBytes(5, storageRecord); + statement.executeUpdate(); + } + } + + private NotificationProfile getNotificationProfileFromResultSet(ResultSet resultSet) throws SQLException { + final var internalId = resultSet.getLong("_id"); + final var profileId = resultSet.getBytes("profile_id"); + final var name = resultSet.getString("name"); + final var deletedTimestamp = resultSet.getLong("deleted_timestamp"); + final var storageIdBytes = resultSet.getBytes("storage_id"); + final var storageId = storageIdBytes == null ? null : StorageId.forNotificationProfile(storageIdBytes); + final var storageRecord = resultSet.getBytes("storage_record"); + return new NotificationProfile(internalId, profileId, name, deletedTimestamp, storageId, storageRecord); + } + + private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException { + final var storageId = resultSet.getBytes("storage_id"); + return StorageId.forNotificationProfile(storageId); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java b/lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java new file mode 100644 index 00000000..ff9fbcae --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java @@ -0,0 +1,79 @@ +package org.asamk.signal.manager.syncStorage; + +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.util.KeyUtils; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +public class NotificationProfileRecordProcessor extends DefaultStorageRecordProcessor { + + private final SignalAccount account; + private final Connection connection; + + public NotificationProfileRecordProcessor(final SignalAccount account, final Connection connection) { + this.account = account; + this.connection = connection; + } + + @Override + public int compare(final SignalNotificationProfileRecord lhs, final SignalNotificationProfileRecord rhs) { + return lhs.getProto().id.equals(rhs.getProto().id) ? 0 : 1; + } + + @Override + protected boolean isInvalid(final SignalNotificationProfileRecord remote) { + return remote.getProto().id.size() == 0; + } + + @Override + protected Optional getMatching(final SignalNotificationProfileRecord remote) throws SQLException { + final var profileId = remote.getProto().id.toByteArray(); + final var local = account.getNotificationProfileStore().getNotificationProfile(connection, profileId); + + if (local == null) { + return Optional.empty(); + } + + final StorageId storageId; + if (local.storageId() != null) { + storageId = local.storageId(); + } else { + storageId = StorageId.forNotificationProfile(KeyUtils.createRawStorageId()); + account.getNotificationProfileStore().updateStorageId(connection, profileId, storageId); + } + + return Optional.of(new SignalNotificationProfileRecord(storageId, StorageSyncModels.localToRemoteRecord(local))); + } + + @Override + protected SignalNotificationProfileRecord merge( + final SignalNotificationProfileRecord remoteRecord, + final SignalNotificationProfileRecord localRecord + ) { + final var remote = remoteRecord.getProto(); + final var local = localRecord.getProto(); + + // Profiles are only ever edited by the official clients, so remote wins. + // The one exception is an older local deletion, which must not be resurrected. + if (StickerPackRecordProcessor.shouldKeepLocalDeletion(remote.deletedAtTimestampMs, + local.deletedAtTimestampMs)) { + return localRecord; + } + + return remoteRecord; + } + + @Override + protected void insertLocal(final SignalNotificationProfileRecord record) throws SQLException { + account.getNotificationProfileStore().upsertFromStorageSync(connection, record); + } + + @Override + protected void updateLocal(final StorageRecordUpdate update) throws SQLException { + account.getNotificationProfileStore().upsertFromStorageSync(connection, update.newRecord()); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java b/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java index d1a77361..ad56c497 100644 --- a/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java +++ b/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java @@ -7,6 +7,7 @@ import org.asamk.signal.manager.storage.configuration.ConfigurationStore; import org.asamk.signal.manager.storage.groups.GroupInfoV1; import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.identities.IdentityInfo; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile; import org.asamk.signal.manager.storage.recipients.Recipient; import org.asamk.signal.manager.storage.stickers.StickerPack; import org.signal.core.models.ServiceId.ACI; @@ -17,6 +18,7 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord; 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.SignalStickerPackRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink; @@ -185,6 +187,15 @@ public final class StorageSyncModels { return builder.build(); } + public static org.whispersystems.signalservice.internal.storage.protos.NotificationProfile localToRemoteRecord( + NotificationProfile notificationProfile + ) { + // Notification profiles are never edited locally, the stored record is the source of truth. + final var builder = SignalNotificationProfileRecord.Companion.newBuilder(notificationProfile.storageRecord()); + builder.id(ByteString.of(notificationProfile.profileId())); + return builder.build(); + } + public static TrustLevel remoteToLocal(IdentityState identityState) { return switch (identityState) { case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED; diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java new file mode 100644 index 00000000..87772aa7 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java @@ -0,0 +1,117 @@ +package org.asamk.signal.manager.storage.notificationProfiles; + +import org.asamk.signal.manager.storage.AccountDatabase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile; + +import java.io.File; +import java.util.List; + +import okio.ByteString; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class NotificationProfileStoreTest { + + @TempDir + File tempDir; + + private AccountDatabase database; + private NotificationProfileStore store; + + @BeforeEach + void setUp() throws Exception { + database = AccountDatabase.init(new File(tempDir, "account.db")); + store = new NotificationProfileStore(database); + } + + @AfterEach + void tearDown() { + database.close(); + } + + private static SignalNotificationProfileRecord record(byte[] id, String name, long deletedAt, byte[] storageId) { + return new SignalNotificationProfileRecord(StorageId.forNotificationProfile(storageId), + new NotificationProfile.Builder().id(ByteString.of(id)) + .name(name) + .emoji("🌙") + .deletedAtTimestampMs(deletedAt) + .build()); + } + + private static byte[] bytes(int seed) { + final var result = new byte[16]; + result[0] = (byte) seed; + return result; + } + + @Test + void upsertInsertsAndUpdates() throws Exception { + final var id = bytes(1); + try (final var connection = database.getConnection()) { + store.upsertFromStorageSync(connection, record(id, "Sleep", 0, bytes(10))); + + final var inserted = store.getNotificationProfile(connection, id); + assertNotNull(inserted); + assertEquals("Sleep", inserted.name()); + assertEquals(0, inserted.deletedTimestamp()); + assertArrayEquals(bytes(10), inserted.storageId().getRaw()); + assertEquals("Sleep", NotificationProfile.ADAPTER.decode(inserted.storageRecord()).name); + + store.upsertFromStorageSync(connection, record(id, "Sleep 2", 42, bytes(11))); + + final var all = store.getNotificationProfiles(connection); + assertEquals(1, all.size()); + final var updated = all.getFirst(); + assertEquals(inserted.internalId(), updated.internalId()); + assertEquals("Sleep 2", updated.name()); + assertEquals(42, updated.deletedTimestamp()); + assertArrayEquals(bytes(11), updated.storageId().getRaw()); + + assertNotNull(store.getNotificationProfile(connection, StorageId.forNotificationProfile(bytes(11)))); + assertNull(store.getNotificationProfile(connection, StorageId.forNotificationProfile(bytes(10)))); + assertEquals(List.of(StorageId.forNotificationProfile(bytes(11))), store.getStorageIds(connection)); + } + } + + @Test + void removesOnlyDeletedLocalOnlyProfiles() throws Exception { + try (final var connection = database.getConnection()) { + store.upsertFromStorageSync(connection, record(bytes(1), "Active", 0, bytes(10))); + store.upsertFromStorageSync(connection, record(bytes(2), "Deleted", 42, bytes(11))); + + final var removed = store.removeLocalOnlyDeletedNotificationProfiles(connection, + List.of(StorageId.forNotificationProfile(bytes(10)), StorageId.forNotificationProfile(bytes(11)))); + + assertEquals(1, removed); + assertNotNull(store.getNotificationProfile(connection, bytes(1))); + assertNull(store.getNotificationProfile(connection, bytes(2))); + } + } + + @Test + void updatesStorageIds() throws Exception { + try (final var connection = database.getConnection()) { + store.upsertFromStorageSync(connection, record(bytes(1), "A", 0, bytes(10))); + store.upsertFromStorageSync(connection, record(bytes(2), "B", 0, bytes(11))); + final var profiles = store.getNotificationProfiles(connection); + + store.updateStorageIds(connection, + java.util.Map.of(profiles.get(0).internalId(), + StorageId.forNotificationProfile(bytes(20)), + profiles.get(1).internalId(), + StorageId.forNotificationProfile(bytes(21)))); + + assertArrayEquals(bytes(20), store.getNotificationProfile(connection, bytes(1)).storageId().getRaw()); + assertArrayEquals(bytes(21), store.getNotificationProfile(connection, bytes(2)).storageId().getRaw()); + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java b/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java index 1ce68a07..7b3e7719 100644 --- a/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java @@ -3,9 +3,15 @@ package org.asamk.signal.manager.syncStorage; import org.junit.jupiter.api.Test; import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile; import java.util.UUID; +import okio.ByteString; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -30,6 +36,29 @@ class StorageRecordProcessorTest { assertFalse(ContactRecordProcessor.shouldUseRemoteIdentityKey(false, false, 33, 33, 0, false)); } + @Test + void notificationProfileMergePrefersRemoteExceptOlderLocalDeletion() { + final var id = ByteString.of(new byte[]{1, 2, 3}); + final var remote = new SignalNotificationProfileRecord(StorageId.forNotificationProfile(new byte[16]), + new NotificationProfile.Builder().id(id).name("Remote").deletedAtTimestampMs(0).build()); + final var local = new SignalNotificationProfileRecord(StorageId.forNotificationProfile(new byte[16]), + new NotificationProfile.Builder().id(id).name("Local").deletedAtTimestampMs(0).build()); + final var processor = new NotificationProfileRecordProcessor(null, null); + + assertEquals(remote, processor.merge(remote, local)); + + final var remoteDeleted = new SignalNotificationProfileRecord(remote.getId(), + remote.getProto().newBuilder().deletedAtTimestampMs(200).build()); + final var localDeleted = new SignalNotificationProfileRecord(local.getId(), + local.getProto().newBuilder().deletedAtTimestampMs(100).build()); + assertEquals(localDeleted, processor.merge(remoteDeleted, localDeleted)); + assertEquals(remoteDeleted, processor.merge(remoteDeleted, local)); + + assertTrue(processor.isInvalid(new SignalNotificationProfileRecord(remote.getId(), + new NotificationProfile.Builder().name("No id").build()))); + assertFalse(processor.isInvalid(remote)); + } + @Test void keepsOlderLocalStickerDeletion() { assertTrue(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 100)); diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json index 1123d24f..eec31fa1 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -9751,6 +9751,29 @@ { "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride" }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$Companion" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$ManuallyEnabled", + "fields": [ + { + "name": "endAtTimestampMs" + }, + { + "name": "id" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$ManuallyEnabled$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$ManuallyEnabled$Companion" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$PhoneNumberSharingMode" }, @@ -9884,6 +9907,59 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile", + "fields": [ + { + "name": "allowAllCalls" + }, + { + "name": "allowAllMentions" + }, + { + "name": "allowedMembers" + }, + { + "name": "color" + }, + { + "name": "createdAtMs" + }, + { + "name": "deletedAtTimestampMs" + }, + { + "name": "emoji" + }, + { + "name": "id" + }, + { + "name": "name" + }, + { + "name": "scheduleDaysEnabled" + }, + { + "name": "scheduleEnabled" + }, + { + "name": "scheduleEndTime" + }, + { + "name": "scheduleStartTime" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile$Companion" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile$DayOfWeek" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.OptionalBool" }, @@ -9899,6 +9975,46 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient", + "fields": [ + { + "name": "contact" + }, + { + "name": "groupMasterKey" + }, + { + "name": "legacyGroupId" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Companion" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Contact", + "fields": [ + { + "name": "e164" + }, + { + "name": "serviceId" + }, + { + "name": "serviceIdBinary" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Contact$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Contact$Companion" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord", "fields": [