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.
This commit is contained in:
SiteRelEnby 2026-09-04 18:33:46 -04:00
parent 52a0f6415e
commit 8e40310f4c
No known key found for this signature in database
GPG Key ID: 75DEE578292BB4CC
10 changed files with 687 additions and 5 deletions

View File

@ -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<GroupIdV1, StorageId> newGroupV1StorageIds;
final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
final Map<StickerPackId, StorageId> newStickerPackStorageIds;
final Map<Long, StorageId> 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<Long, StorageId> generateNotificationProfileStorageIds(
final List<NotificationProfile> 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);
}

View File

@ -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(

View File

@ -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()));
}

View File

@ -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
) {}

View File

@ -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<NotificationProfile> 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<NotificationProfile> 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<StorageId> 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<Long, StorageId> 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<StorageId> 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);
}
}

View File

@ -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<SignalNotificationProfileRecord> {
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<SignalNotificationProfileRecord> 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<SignalNotificationProfileRecord> update) throws SQLException {
account.getNotificationProfileStore().upsertFromStorageSync(connection, update.newRecord());
}
}

View File

@ -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;

View File

@ -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());
}
}
}

View File

@ -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));

View File

@ -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": [