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 1/3] 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": [ From 8534a14602c7952c6f47c4d69eb5234eab710f6a Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:34:08 -0400 Subject: [PATCH 2/3] Add notification profile commands New listNotificationProfiles command to show the notification profiles of the account and the current manual override, and a new setNotificationProfile command to manually turn a profile on for a duration or until a point in time, turn it off, or clear the manual override. The override is written to the account storage record and synced to the other devices, the same way the official clients do it. Durations accept compact forms (90m, 1h37m), words (2 hours 30 minutes), h:mm and ISO-8601. End times accept times of day, dates, ISO timestamps, unix timestamps and "forever". --- CHANGELOG.md | 6 + .../org/asamk/signal/manager/Manager.java | 20 ++ .../manager/api/NotificationProfile.java | 36 ++ .../NotificationProfileNotFoundException.java | 8 + .../api/NotificationProfileOverride.java | 42 +++ .../signal/manager/internal/ManagerImpl.java | 75 ++++ .../util/NotificationProfileUtils.java | 167 +++++++++ .../util/NotificationProfileUtilsTest.java | 115 ++++++ man/signal-cli.1.adoc | 40 +++ .../org/asamk/signal/commands/Commands.java | 2 + .../ListNotificationProfilesCommand.java | 168 +++++++++ .../SetNotificationProfileCommand.java | 221 ++++++++++++ .../asamk/signal/dbus/DbusManagerImpl.java | 17 + .../org/asamk/signal/util/TimeParser.java | 328 ++++++++++++++++++ .../org/asamk/signal/util/TimeParserTest.java | 170 +++++++++ 15 files changed, 1415 insertions(+) create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/util/NotificationProfileUtils.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/util/NotificationProfileUtilsTest.java create mode 100644 src/main/java/org/asamk/signal/commands/ListNotificationProfilesCommand.java create mode 100644 src/main/java/org/asamk/signal/commands/SetNotificationProfileCommand.java create mode 100644 src/main/java/org/asamk/signal/util/TimeParser.java create mode 100644 src/test/java/org/asamk/signal/util/TimeParserTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a4c0ce..5cee7a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- Sync notification profiles via storage sync +- New `listNotificationProfiles` command to show notification profiles and the current manual override +- New `setNotificationProfile` command to manually turn a notification profile on for any duration or until any point in time, or off + ## [0.14.8] - 2026-09-10 ### Added diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 7d0c73af..c6c39b19 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -30,6 +30,9 @@ import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NotAGroupMemberException; import org.asamk.signal.manager.api.NotPrimaryDeviceException; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileNotFoundException; +import org.asamk.signal.manager.api.NotificationProfileOverride; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.PendingAdminApprovalException; import org.asamk.signal.manager.api.PinLockMissingException; @@ -120,6 +123,23 @@ public interface Manager extends Closeable { void updateConfiguration(Configuration configuration) throws NotPrimaryDeviceException; + /** + * Get the notification profiles known for this account. + * Profiles are created and edited in the official clients and received via storage sync. + */ + List getNotificationProfiles(); + + /** + * Get the current manual notification profile override (manually enabled or disabled profile). + */ + NotificationProfileOverride getNotificationProfileOverride(); + + /** + * Manually enable or disable a notification profile, or clear the manual override. + * The change is synced to all devices via storage service. + */ + void setNotificationProfileOverride(NotificationProfileOverride override) throws NotificationProfileNotFoundException; + /** * Update the user's profile. * If a field is null, the previous value will be kept. diff --git a/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java new file mode 100644 index 00000000..699f1e5b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java @@ -0,0 +1,36 @@ +package org.asamk.signal.manager.api; + +import java.time.DayOfWeek; +import java.util.List; +import java.util.Optional; + +/** + * A notification profile, as configured in one of the official clients and synced via storage service. + * + * @param id The raw profile id, unique per account. + * @param name The user visible profile name. + * @param emoji Optional emoji shown next to the name. + * @param color The color as ARGB integer. + * @param createdAt Creation timestamp in milliseconds since epoch. + * @param allowAllCalls If true, calls from everyone are allowed while the profile is active. + * @param allowAllMentions If true, mentions from everyone are allowed while the profile is active. + * @param allowedMembers Contacts and groups whose notifications are still shown while the profile is active. + * @param scheduleEnabled If true, the profile is activated automatically according to the schedule. + * @param scheduleStart Schedule start time as HHMM (e.g. 900 for 9:00, 2230 for 22:30). + * @param scheduleEnd Schedule end time as HHMM. + * @param scheduleDaysEnabled Days of the week the schedule is active on. + */ +public record NotificationProfile( + byte[] id, + String name, + Optional emoji, + int color, + long createdAt, + boolean allowAllCalls, + boolean allowAllMentions, + List allowedMembers, + boolean scheduleEnabled, + int scheduleStart, + int scheduleEnd, + List scheduleDaysEnabled +) {} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java new file mode 100644 index 00000000..e74495f1 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.api; + +public class NotificationProfileNotFoundException extends Exception { + + public NotificationProfileNotFoundException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java new file mode 100644 index 00000000..e19518d3 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java @@ -0,0 +1,42 @@ +package org.asamk.signal.manager.api; + +/** + * The manual override state for notification profiles, shared between all devices of an account. + *

+ * Without an override the official clients activate profiles purely according to their schedule. + */ +public sealed interface NotificationProfileOverride { + + /** + * No manual override, the schedule (if any) decides which profile is active. + */ + record None() implements NotificationProfileOverride {} + + /** + * A profile was manually turned off. + * The official clients keep scheduled profiles off until the schedule window that was active at + * {@code disabledAt} has passed. + * + * @param disabledAt Timestamp in milliseconds since epoch. + */ + record Disabled(long disabledAt) implements NotificationProfileOverride {} + + /** + * A profile was manually turned on. + * + * @param profileId The id of the enabled profile. + * @param endAt Timestamp in milliseconds since epoch at which the profile is turned off again, + * or {@link #END_NEVER} to keep it on until it is manually turned off. + */ + record Enabled(byte[] profileId, long endAt) implements NotificationProfileOverride { + + public boolean isIndefinite() { + return endAt == END_NEVER; + } + } + + /** + * Sentinel end timestamp meaning "until manually turned off". + */ + long END_NEVER = Long.MAX_VALUE; +} diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 173faf36..b0f0e948 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -47,6 +47,9 @@ import org.asamk.signal.manager.api.MessageEnvelope.Sync.MessageRequestResponse; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NotAGroupMemberException; import org.asamk.signal.manager.api.NotPrimaryDeviceException; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileNotFoundException; +import org.asamk.signal.manager.api.NotificationProfileOverride; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.PendingAdminApprovalException; import org.asamk.signal.manager.api.PhoneNumberSharingMode; @@ -93,6 +96,7 @@ import org.asamk.signal.manager.storage.stickers.StickerPack; import org.asamk.signal.manager.util.AttachmentUtils; import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.MimeUtils; +import org.asamk.signal.manager.util.NotificationProfileUtils; import org.asamk.signal.manager.util.PhoneNumberFormatter; import org.asamk.signal.manager.util.StickerUtils; import org.signal.core.models.ServiceId; @@ -124,7 +128,10 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.util.StreamDetails; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.util.Util; import java.io.ByteArrayInputStream; @@ -135,6 +142,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.sql.SQLException; import java.time.Duration; import java.util.ArrayList; import java.util.Collection; @@ -386,6 +394,73 @@ public class ManagerImpl implements Manager { syncRemoteStorage(); } + @Override + public List getNotificationProfiles() { + final var profiles = new ArrayList(); + for (final var local : account.getNotificationProfileStore().getNotificationProfiles()) { + if (local.deletedTimestamp() > 0 || local.storageRecord() == null) { + continue; + } + try { + profiles.add(NotificationProfileUtils.toApi(local)); + } catch (IOException e) { + logger.warn("Ignoring notification profile {} with invalid storage record", + NotificationProfileUtils.formatProfileId(local.profileId())); + } + } + return profiles; + } + + @Override + public NotificationProfileOverride getNotificationProfileOverride() { + final byte[] storageRecord; + try (final var connection = account.getAccountDatabase().getConnection()) { + storageRecord = account.getRecipientStore() + .getRecipient(connection, account.getSelfRecipientId()) + .getStorageRecord(); + } catch (SQLException e) { + throw new RuntimeException("Failed to read notification profile override", e); + } + if (storageRecord == null) { + return new NotificationProfileOverride.None(); + } + try { + return NotificationProfileUtils.toApi(AccountRecord.ADAPTER.decode(storageRecord).notificationProfileManualOverride); + } catch (IOException e) { + logger.warn("Failed to decode local account storage record", e); + return new NotificationProfileOverride.None(); + } + } + + @Override + public void setNotificationProfileOverride(final NotificationProfileOverride override) throws NotificationProfileNotFoundException { + try (final var connection = account.getAccountDatabase().getConnection()) { + connection.setAutoCommit(false); + if (override instanceof NotificationProfileOverride.Enabled enabled) { + final var profile = account.getNotificationProfileStore() + .getNotificationProfile(connection, enabled.profileId()); + if (profile == null || profile.deletedTimestamp() > 0) { + throw new NotificationProfileNotFoundException("Notification profile not found: " + + NotificationProfileUtils.formatProfileId(enabled.profileId())); + } + } + + final var selfRecipientId = account.getSelfRecipientId(); + final var self = account.getRecipientStore().getRecipient(connection, selfRecipientId); + final var builder = SignalAccountRecord.Companion.newBuilder(self.getStorageRecord()); + builder.notificationProfileManualOverride(NotificationProfileUtils.toProto(override)); + + // Store the updated record under a fresh storage id, so the next sync pushes it + final var storageId = StorageId.forAccount(KeyUtils.createRawStorageId()); + account.getRecipientStore() + .storeStorageRecord(connection, selfRecipientId, storageId, builder.build().encode()); + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed to update notification profile override", e); + } + syncRemoteStorage(); + } + @Override public void updateProfile(UpdateProfile updateProfile) throws IOException { context.getProfileHelper() diff --git a/lib/src/main/java/org/asamk/signal/manager/util/NotificationProfileUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/NotificationProfileUtils.java new file mode 100644 index 00000000..f2cf033b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/util/NotificationProfileUtils.java @@ -0,0 +1,167 @@ +package org.asamk.signal.manager.util; + +import org.asamk.signal.manager.api.GroupIdV1; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileOverride; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.groups.GroupUtils; +import org.signal.core.models.ServiceId; +import org.signal.core.models.ServiceId.ACI; +import org.signal.core.models.ServiceId.PNI; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.groups.GroupMasterKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; +import org.whispersystems.signalservice.internal.storage.protos.Recipient; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.DayOfWeek; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import okio.ByteString; + +public final class NotificationProfileUtils { + + private static final Logger logger = LoggerFactory.getLogger(NotificationProfileUtils.class); + + private NotificationProfileUtils() { + } + + public static NotificationProfile toApi( + final org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile local + ) throws IOException { + final var proto = org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.ADAPTER.decode( + local.storageRecord()); + return new NotificationProfile(local.profileId(), + proto.name, + Optional.ofNullable(proto.emoji).filter(e -> !e.isEmpty()), + proto.color, + proto.createdAtMs, + proto.allowAllCalls, + proto.allowAllMentions, + toRecipientIdentifiers(proto.allowedMembers), + proto.scheduleEnabled, + proto.scheduleStartTime, + proto.scheduleEndTime, + proto.scheduleDaysEnabled.stream().map(NotificationProfileUtils::toDayOfWeek).flatMap(Optional::stream).toList()); + } + + public static NotificationProfileOverride toApi(final AccountRecord.NotificationProfileManualOverride override) { + if (override == null) { + return new NotificationProfileOverride.None(); + } + if (override.enabled != null) { + return new NotificationProfileOverride.Enabled(override.enabled.id.toByteArray(), + override.enabled.endAtTimestampMs); + } + if (override.disabledAtTimestampMs != null) { + return new NotificationProfileOverride.Disabled(override.disabledAtTimestampMs); + } + return new NotificationProfileOverride.None(); + } + + public static AccountRecord.NotificationProfileManualOverride toProto(final NotificationProfileOverride override) { + return switch (override) { + case NotificationProfileOverride.None _ -> null; + case NotificationProfileOverride.Disabled disabled -> + new AccountRecord.NotificationProfileManualOverride.Builder().disabledAtTimestampMs(disabled.disabledAt()) + .build(); + case NotificationProfileOverride.Enabled enabled -> + new AccountRecord.NotificationProfileManualOverride.Builder().enabled(new AccountRecord.NotificationProfileManualOverride.ManuallyEnabled.Builder().id( + ByteString.of(enabled.profileId())).endAtTimestampMs(enabled.endAt()).build()).build(); + }; + } + + /** + * Format a profile id for display. Official clients use UUIDs, anything else is shown as hex. + */ + public static String formatProfileId(final byte[] id) { + if (id.length == 16) { + final var buffer = ByteBuffer.wrap(id); + return new UUID(buffer.getLong(), buffer.getLong()).toString(); + } + return HexFormat.of().formatHex(id); + } + + /** + * Parse a profile id as given by the user, either as UUID or as hex string. + * + * @return the raw id, or empty if the input is neither a UUID nor a hex string + */ + public static Optional parseProfileId(final String id) { + final var trimmed = id.trim(); + try { + final var uuid = UUID.fromString(trimmed); + final var buffer = ByteBuffer.allocate(16); + buffer.putLong(uuid.getMostSignificantBits()); + buffer.putLong(uuid.getLeastSignificantBits()); + return Optional.of(buffer.array()); + } catch (IllegalArgumentException ignored) { + } + final var hex = trimmed.replace(":", "").replace(" ", ""); + if (hex.isEmpty() || hex.length() % 2 != 0) { + return Optional.empty(); + } + try { + return Optional.of(HexFormat.of().parseHex(hex)); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + + private static List toRecipientIdentifiers(final List recipients) { + final var result = new ArrayList(); + for (final var recipient : recipients) { + if (recipient.groupMasterKey != null && recipient.groupMasterKey.size() > 0) { + try { + final var masterKey = new GroupMasterKey(recipient.groupMasterKey.toByteArray()); + result.add(new RecipientIdentifier.Group(GroupUtils.getGroupIdV2(masterKey))); + } catch (InvalidInputException e) { + logger.debug("Ignoring notification profile member with invalid group master key"); + } + } else if (recipient.legacyGroupId != null && recipient.legacyGroupId.size() > 0) { + result.add(new RecipientIdentifier.Group(new GroupIdV1(recipient.legacyGroupId.toByteArray()))); + } else if (recipient.contact != null) { + final var contact = recipient.contact; + ServiceId serviceId = null; + if (contact.serviceIdBinary != null && contact.serviceIdBinary.size() > 0) { + serviceId = ServiceId.parseOrNull(contact.serviceIdBinary.toByteArray()); + } + if (serviceId == null && contact.serviceId != null && !contact.serviceId.isEmpty()) { + serviceId = ServiceId.parseOrNull(contact.serviceId); + } + if (serviceId instanceof ACI aci) { + result.add(new RecipientIdentifier.Uuid(aci.getRawUuid())); + } else if (serviceId instanceof PNI pni) { + result.add(new RecipientIdentifier.Pni(pni.getRawUuid())); + } else if (contact.e164 != null && !contact.e164.isEmpty()) { + result.add(new RecipientIdentifier.Number(contact.e164)); + } else { + logger.debug("Ignoring notification profile member without usable identifier"); + } + } + } + return result; + } + + private static Optional toDayOfWeek( + final org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek day + ) { + return switch (day) { + case MONDAY -> Optional.of(DayOfWeek.MONDAY); + case TUESDAY -> Optional.of(DayOfWeek.TUESDAY); + case WEDNESDAY -> Optional.of(DayOfWeek.WEDNESDAY); + case THURSDAY -> Optional.of(DayOfWeek.THURSDAY); + case FRIDAY -> Optional.of(DayOfWeek.FRIDAY); + case SATURDAY -> Optional.of(DayOfWeek.SATURDAY); + case SUNDAY -> Optional.of(DayOfWeek.SUNDAY); + case UNKNOWN -> Optional.empty(); + }; + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/util/NotificationProfileUtilsTest.java b/lib/src/test/java/org/asamk/signal/manager/util/NotificationProfileUtilsTest.java new file mode 100644 index 00000000..7a96f3b3 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/util/NotificationProfileUtilsTest.java @@ -0,0 +1,115 @@ +package org.asamk.signal.manager.util; + +import org.asamk.signal.manager.api.NotificationProfileOverride; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile; +import org.junit.jupiter.api.Test; +import org.signal.core.models.ServiceId.ACI; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; +import org.whispersystems.signalservice.internal.storage.protos.Recipient; + +import java.time.DayOfWeek; +import java.util.List; +import java.util.UUID; + +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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NotificationProfileUtilsTest { + + @Test + void formatsUuidIdsAsUuid() { + final var uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + final var raw = NotificationProfileUtils.parseProfileId(uuid.toString()).orElseThrow(); + assertEquals(16, raw.length); + assertEquals(uuid.toString(), NotificationProfileUtils.formatProfileId(raw)); + } + + @Test + void formatsOtherIdsAsHex() { + final var raw = new byte[]{1, 2, 3, (byte) 0xab}; + assertEquals("010203ab", NotificationProfileUtils.formatProfileId(raw)); + assertArrayEquals(raw, NotificationProfileUtils.parseProfileId("010203AB").orElseThrow()); + assertArrayEquals(raw, NotificationProfileUtils.parseProfileId("01:02:03:ab").orElseThrow()); + } + + @Test + void rejectsNonIds() { + assertTrue(NotificationProfileUtils.parseProfileId("Work").isEmpty()); + assertTrue(NotificationProfileUtils.parseProfileId("").isEmpty()); + assertTrue(NotificationProfileUtils.parseProfileId("abc").isEmpty()); + } + + @Test + void overrideRoundTrip() { + assertNull(NotificationProfileUtils.toProto(new NotificationProfileOverride.None())); + assertInstanceOf(NotificationProfileOverride.None.class, NotificationProfileUtils.toApi((AccountRecord.NotificationProfileManualOverride) null)); + + final var disabled = new NotificationProfileOverride.Disabled(1234L); + final var disabledProto = NotificationProfileUtils.toProto(disabled); + assertEquals(1234L, disabledProto.disabledAtTimestampMs); + assertNull(disabledProto.enabled); + assertEquals(disabled, NotificationProfileUtils.toApi(disabledProto)); + + final var id = new byte[]{9, 8, 7}; + final var enabled = new NotificationProfileOverride.Enabled(id, 5678L); + final var enabledProto = NotificationProfileUtils.toProto(enabled); + assertNull(enabledProto.disabledAtTimestampMs); + assertEquals(ByteString.of(id), enabledProto.enabled.id); + assertEquals(5678L, enabledProto.enabled.endAtTimestampMs); + final var back = assertInstanceOf(NotificationProfileOverride.Enabled.class, + NotificationProfileUtils.toApi(enabledProto)); + assertArrayEquals(id, back.profileId()); + assertEquals(5678L, back.endAt()); + + final var forever = new NotificationProfileOverride.Enabled(id, NotificationProfileOverride.END_NEVER); + assertTrue(forever.isIndefinite()); + assertTrue(assertInstanceOf(NotificationProfileOverride.Enabled.class, + NotificationProfileUtils.toApi(NotificationProfileUtils.toProto(forever))).isIndefinite()); + } + + @Test + void convertsStoredProfileToApi() throws Exception { + final var aci = ACI.from(UUID.randomUUID()); + final var id = ByteString.of(new byte[]{1, 2, 3, 4}); + final var proto = new org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.Builder().id( + id) + .name("Work") + .emoji("💼") + .color(0xff00ff00) + .createdAtMs(42L) + .allowAllCalls(true) + .allowAllMentions(false) + .allowedMembers(List.of(new Recipient.Builder().contact(new Recipient.Contact.Builder().serviceId(aci.toString()) + .build()).build(), + new Recipient.Builder().contact(new Recipient.Contact.Builder().e164("+12025550123").build()) + .build())) + .scheduleEnabled(true) + .scheduleStartTime(900) + .scheduleEndTime(1730) + .scheduleDaysEnabled(List.of(org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek.MONDAY, + org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek.FRIDAY, + org.whispersystems.signalservice.internal.storage.protos.NotificationProfile.DayOfWeek.UNKNOWN)) + .build(); + final var local = new NotificationProfile(1, id.toByteArray(), "Work", 0, null, proto.encode()); + + final var api = NotificationProfileUtils.toApi(local); + assertArrayEquals(id.toByteArray(), api.id()); + assertEquals("Work", api.name()); + assertEquals("💼", api.emoji().orElseThrow()); + assertEquals(0xff00ff00, api.color()); + assertEquals(42L, api.createdAt()); + assertTrue(api.allowAllCalls()); + assertEquals(List.of(new RecipientIdentifier.Uuid(aci.getRawUuid()), + new RecipientIdentifier.Number("+12025550123")), api.allowedMembers()); + assertTrue(api.scheduleEnabled()); + assertEquals(900, api.scheduleStart()); + assertEquals(1730, api.scheduleEnd()); + assertEquals(List.of(DayOfWeek.MONDAY, DayOfWeek.FRIDAY), api.scheduleDaysEnabled()); + } +} diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 6fcdfee1..d3ba3682 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -231,6 +231,46 @@ Indicates if Signal should send/show typing indicators. *--link-previews* {true,false}:: Indicates if Signal should generate link previews. +=== listNotificationProfiles + +Show the notification profiles of this account and the current manual override. +Notification profiles are created and edited in the official Signal apps and are received via storage sync, +so run receive (or any other command that triggers a sync) at least once after creating a profile. + +In JSON output mode an object with the fields `manualOverride` and `profiles` is returned. + +=== setNotificationProfile + +Manually turn a notification profile on or off. +The change is synced to all devices via storage service, the same way the official apps do it. +Unlike the official apps, any duration or end time can be chosen. + +*-p* PROFILE, *--profile* PROFILE:: +Name (case insensitive) or id of the notification profile to turn on. +Required together with `--for` or `--until`. + +*--for* DURATION:: +Turn the profile on for the given duration. +Accepts compact durations (`90m`, `1h37m`, `1d 2h 30m 15s`), words (`2 hours 30 minutes`, `an hour`), +decimals (`1.5h`), `H:MM` or `H:MM:SS` and ISO-8601 durations (`PT2H30M`). +Supported units are ms, s/sec/seconds, m/min/minutes, h/hr/hours, d/days and w/weeks. +A plain number without unit is rejected. + +*--until* TIME:: +Turn the profile on until the given point in time. +Accepts a time of day (`18:00`, `6:30pm`, `noon`, `midnight`), optionally prefixed with `today` or `tomorrow`, +a date and time (`2026-12-24 18:00`, `2026-12-24T18:00:00Z`, `2026-12-24T18:00:00+01:00`), a date (`2026-12-24`, start of day), +a unix timestamp in seconds or milliseconds, or `forever` to keep the profile on until it is turned off manually. +A bare time of day refers to the next occurrence of that time. +Times without an explicit zone are interpreted in the local time zone. + +*--disable*:: +Turn the currently active profile off. +A scheduled profile stays off until its current schedule window ends. + +*--clear*:: +Remove the manual override, so profiles are activated by their schedule again. + === setPin Set a registration lock pin, to prevent others from registering your account's phone number. diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index a57c8c97..6b99ff25 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -33,6 +33,7 @@ public class Commands { addCommand(new ListGroupsCommand()); addCommand(new ListIdentitiesCommand()); addCommand(new ListStickerPacksCommand()); + addCommand(new ListNotificationProfilesCommand()); addCommand(new QuitGroupCommand()); addCommand(new ReceiveCommand()); addCommand(new RejectCallCommand()); @@ -67,6 +68,7 @@ public class Commands { addCommand(new UnregisterCommand()); addCommand(new UpdateAccountCommand()); addCommand(new UpdateConfigurationCommand()); + addCommand(new SetNotificationProfileCommand()); addCommand(new UpdateContactCommand()); addCommand(new UpdateDeviceCommand()); addCommand(new UpdateGroupCommand()); diff --git a/src/main/java/org/asamk/signal/commands/ListNotificationProfilesCommand.java b/src/main/java/org/asamk/signal/commands/ListNotificationProfilesCommand.java new file mode 100644 index 00000000..5582ee93 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/ListNotificationProfilesCommand.java @@ -0,0 +1,168 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileOverride; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.util.NotificationProfileUtils; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; +import org.asamk.signal.util.DateUtils; + +import java.time.DayOfWeek; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +public class ListNotificationProfilesCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "listNotificationProfiles"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Show the notification profiles of this account and the current manual override."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + final var profiles = m.getNotificationProfiles(); + final var override = m.getNotificationProfileOverride(); + + switch (outputWriter) { + case JsonWriter writer -> writer.write(new JsonNotificationProfiles(JsonManualOverride.from(override), + profiles.stream().map(p -> new JsonNotificationProfile(p, override)).toList())); + case PlainTextWriter writer -> { + writer.println("Manual override: {}", describeOverride(override, profiles)); + for (final var profile : profiles) { + writer.println("Profile: “{}” Id: {} Emoji: {} Manually enabled: {} Schedule: {}", + profile.name(), + NotificationProfileUtils.formatProfileId(profile.id()), + profile.emoji().orElse(""), + isManuallyEnabled(profile, override), + describeSchedule(profile)); + writer.indentedWriter() + .println("Allow all calls: {} Allow all mentions: {} Allowed members: {} Created: {}", + profile.allowAllCalls(), + profile.allowAllMentions(), + profile.allowedMembers() + .stream() + .map(RecipientIdentifier::getIdentifier) + .collect(Collectors.joining(", ")), + DateUtils.formatTimestamp(profile.createdAt())); + } + } + } + } + + static boolean isManuallyEnabled(final NotificationProfile profile, final NotificationProfileOverride override) { + return override instanceof NotificationProfileOverride.Enabled enabled && Arrays.equals(enabled.profileId(), + profile.id()); + } + + static String describeOverride( + final NotificationProfileOverride override, + final List profiles + ) { + return switch (override) { + case NotificationProfileOverride.None _ -> "none"; + case NotificationProfileOverride.Disabled disabled -> + "disabled at " + DateUtils.formatTimestamp(disabled.disabledAt()); + case NotificationProfileOverride.Enabled enabled -> { + final var name = profiles.stream() + .filter(p -> Arrays.equals(p.id(), enabled.profileId())) + .map(p -> "“" + p.name() + "”") + .findFirst() + .orElse("unknown profile"); + yield "enabled " + + name + + " (" + + NotificationProfileUtils.formatProfileId(enabled.profileId()) + + ") " + + (enabled.isIndefinite() + ? "until turned off" + : "until " + DateUtils.formatTimestamp(enabled.endAt())); + } + }; + } + + private static String describeSchedule(final NotificationProfile profile) { + if (!profile.scheduleEnabled()) { + return "disabled"; + } + return formatScheduleTime(profile.scheduleStart()) + + "-" + + formatScheduleTime(profile.scheduleEnd()) + + " on " + + profile.scheduleDaysEnabled() + .stream() + .map(d -> d.name().substring(0, 3).toLowerCase(Locale.ROOT)) + .collect(Collectors.joining(",")); + } + + private static String formatScheduleTime(final int hhmm) { + return "%02d:%02d".formatted(hhmm / 100, hhmm % 100); + } + + private record JsonNotificationProfiles(JsonManualOverride manualOverride, List profiles) {} + + private record JsonManualOverride(String state, String profileId, Long endAt, Long disabledAt) { + + static JsonManualOverride from(final NotificationProfileOverride override) { + return switch (override) { + case NotificationProfileOverride.None _ -> new JsonManualOverride("none", null, null, null); + case NotificationProfileOverride.Disabled disabled -> + new JsonManualOverride("disabled", null, null, disabled.disabledAt()); + case NotificationProfileOverride.Enabled enabled -> new JsonManualOverride("enabled", + NotificationProfileUtils.formatProfileId(enabled.profileId()), + enabled.isIndefinite() ? null : enabled.endAt(), + null); + }; + } + } + + private record JsonNotificationProfile( + String id, + String name, + String emoji, + int color, + long createdAt, + boolean manuallyEnabled, + boolean allowAllCalls, + boolean allowAllMentions, + List allowedMembers, + boolean scheduleEnabled, + String scheduleStart, + String scheduleEnd, + List scheduleDaysEnabled + ) { + + JsonNotificationProfile(final NotificationProfile profile, final NotificationProfileOverride override) { + this(NotificationProfileUtils.formatProfileId(profile.id()), + profile.name(), + profile.emoji().orElse(null), + profile.color(), + profile.createdAt(), + isManuallyEnabled(profile, override), + profile.allowAllCalls(), + profile.allowAllMentions(), + profile.allowedMembers().stream().map(RecipientIdentifier::getIdentifier).toList(), + profile.scheduleEnabled(), + formatScheduleTime(profile.scheduleStart()), + formatScheduleTime(profile.scheduleEnd()), + profile.scheduleDaysEnabled()); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/SetNotificationProfileCommand.java b/src/main/java/org/asamk/signal/commands/SetNotificationProfileCommand.java new file mode 100644 index 00000000..bbb58a90 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/SetNotificationProfileCommand.java @@ -0,0 +1,221 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.impl.Arguments; +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileNotFoundException; +import org.asamk.signal.manager.api.NotificationProfileOverride; +import org.asamk.signal.manager.util.NotificationProfileUtils; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; +import org.asamk.signal.util.DateUtils; +import org.asamk.signal.util.TimeParser; + +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class SetNotificationProfileCommand implements JsonRpcLocalCommand { + + private static final Set FOREVER_KEYWORDS = Set.of("forever", "indefinitely", "never", "always", "manual"); + + @Override + public String getName() { + return "setNotificationProfile"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Manually turn a notification profile on or off, synced to all devices."); + subparser.addArgument("-p", "--profile") + .help("Name or id of the notification profile to turn on (required with --for / --until)."); + final var mut = subparser.addMutuallyExclusiveGroup().required(true); + mut.addArgument("--for") + .dest("for") + .help("Turn the profile on for a duration, e.g. \"90m\", \"1h37m\", \"2 hours 30 minutes\", \"1:30\", \"PT2H\"."); + mut.addArgument("--until") + .help("Turn the profile on until a point in time, e.g. \"18:00\", \"6:30pm\", \"tomorrow 9:00\", \"2026-12-24T18:00\", a unix timestamp in seconds or milliseconds, or \"forever\" to keep it on until turned off manually."); + mut.addArgument("--disable") + .action(Arguments.storeTrue()) + .help("Turn the currently active profile off. A scheduled profile stays off until its current schedule window ends."); + mut.addArgument("--clear") + .action(Arguments.storeTrue()) + .help("Remove the manual override, so profiles are activated by their schedule again."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + final var profileArg = ns.getString("profile"); + final var forArg = ns.getString("for"); + final var untilArg = ns.getString("until"); + final var disable = Boolean.TRUE.equals(ns.getBoolean("disable")); + final var clear = Boolean.TRUE.equals(ns.getBoolean("clear")); + + final var clock = Clock.systemDefaultZone(); + final NotificationProfileOverride override; + NotificationProfile profile = null; + if (disable || clear) { + if (profileArg != null) { + throw new UserErrorException("--profile can't be combined with --disable or --clear"); + } + override = disable + ? new NotificationProfileOverride.Disabled(clock.millis()) + : new NotificationProfileOverride.None(); + } else { + if (profileArg == null) { + throw new UserErrorException("--profile is required to turn a notification profile on"); + } + profile = findProfile(m.getNotificationProfiles(), profileArg); + final long endAt; + if (forArg != null) { + endAt = clock.millis() + parseDuration(forArg).toMillis(); + } else { + endAt = parseUntil(untilArg, clock); + } + override = new NotificationProfileOverride.Enabled(profile.id(), endAt); + } + + try { + m.setNotificationProfileOverride(override); + } catch (NotificationProfileNotFoundException e) { + throw new UserErrorException(e.getMessage()); + } + + switch (outputWriter) { + case JsonWriter writer -> writer.write(toJson(override, profile)); + case PlainTextWriter writer -> writer.println("{}", describe(override, profile, clock)); + } + } + + static NotificationProfile findProfile( + final List profiles, + final String identifier + ) throws UserErrorException { + if (profiles.isEmpty()) { + throw new UserErrorException( + "No notification profiles known. Create one in a Signal app and make sure storage sync has run (e.g. via receive)."); + } + final var idBytes = NotificationProfileUtils.parseProfileId(identifier); + if (idBytes.isPresent()) { + final var byId = profiles.stream().filter(p -> Arrays.equals(p.id(), idBytes.get())).findFirst(); + if (byId.isPresent()) { + return byId.get(); + } + } + final var byName = profiles.stream() + .filter(p -> p.name().equalsIgnoreCase(identifier.trim())) + .toList(); + if (byName.size() == 1) { + return byName.getFirst(); + } + if (byName.size() > 1) { + throw new UserErrorException("Multiple notification profiles are named \"" + + identifier + + "\", use the id instead: " + + byName.stream() + .map(p -> NotificationProfileUtils.formatProfileId(p.id())) + .collect(Collectors.joining(", "))); + } + throw new UserErrorException("Notification profile not found: \"" + + identifier + + "\". Known profiles: " + + profiles.stream() + .map(p -> "\"" + p.name() + "\" (" + NotificationProfileUtils.formatProfileId(p.id()) + ")") + .collect(Collectors.joining(", "))); + } + + static Duration parseDuration(final String forArg) throws UserErrorException { + try { + return TimeParser.parseDuration(forArg); + } catch (TimeParser.TimeParseException e) { + throw new UserErrorException("Invalid --for value: " + e.getMessage()); + } + } + + static long parseUntil(final String untilArg, final Clock clock) throws UserErrorException { + if (FOREVER_KEYWORDS.contains(untilArg.trim().toLowerCase(Locale.ROOT))) { + return NotificationProfileOverride.END_NEVER; + } + final long endAt; + try { + endAt = TimeParser.parseTimestamp(untilArg, clock); + } catch (TimeParser.TimeParseException e) { + if (TimeParser.looksLikeDuration(untilArg)) { + throw new UserErrorException("Invalid --until value: \"" + + untilArg + + "\" looks like a duration, use --for instead"); + } + throw new UserErrorException("Invalid --until value: " + e.getMessage()); + } + if (endAt <= clock.millis()) { + throw new UserErrorException("Invalid --until value: " + + DateUtils.formatTimestamp(endAt) + + " is in the past"); + } + return endAt; + } + + private static String describe( + final NotificationProfileOverride override, + final NotificationProfile profile, + final Clock clock + ) { + return switch (override) { + case NotificationProfileOverride.None _ -> "Cleared manual notification profile override"; + case NotificationProfileOverride.Disabled disabled -> + "Disabled notification profile at " + DateUtils.formatTimestamp(disabled.disabledAt()); + case NotificationProfileOverride.Enabled enabled -> { + final var name = profile == null ? "" : "“" + profile.name() + "” "; + final var id = NotificationProfileUtils.formatProfileId(enabled.profileId()); + if (enabled.isIndefinite()) { + yield "Enabled notification profile " + name + "(" + id + ") until turned off"; + } + final var remaining = Duration.ofMillis(enabled.endAt() - clock.millis()); + yield "Enabled notification profile " + + name + + "(" + + id + + ") until " + + DateUtils.formatTimestamp(enabled.endAt()) + + ", that's in " + + TimeParser.formatDuration(remaining); + } + }; + } + + private static Map toJson( + final NotificationProfileOverride override, + final NotificationProfile profile + ) { + return switch (override) { + case NotificationProfileOverride.None _ -> Map.of("state", "none"); + case NotificationProfileOverride.Disabled disabled -> + Map.of("state", "disabled", "disabledAt", disabled.disabledAt()); + case NotificationProfileOverride.Enabled enabled -> { + final var result = new java.util.LinkedHashMap(); + result.put("state", "enabled"); + result.put("profileId", NotificationProfileUtils.formatProfileId(enabled.profileId())); + if (profile != null) { + result.put("profileName", profile.name()); + } + result.put("endAt", enabled.isIndefinite() ? null : enabled.endAt()); + yield result; + } + }; + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 74dc12a1..7c27921d 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -31,6 +31,8 @@ import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NotAGroupMemberException; import org.asamk.signal.manager.api.NotPrimaryDeviceException; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileOverride; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.RateLimitException; @@ -690,6 +692,21 @@ public class DbusManagerImpl implements Manager { throw new UnsupportedOperationException(); } + @Override + public List getNotificationProfiles() { + throw new UnsupportedOperationException(); + } + + @Override + public NotificationProfileOverride getNotificationProfileOverride() { + throw new UnsupportedOperationException(); + } + + @Override + public void setNotificationProfileOverride(final NotificationProfileOverride override) { + throw new UnsupportedOperationException(); + } + @Override public void requestAllSyncData() throws IOException { signal.sendSyncRequest(); diff --git a/src/main/java/org/asamk/signal/util/TimeParser.java b/src/main/java/org/asamk/signal/util/TimeParser.java new file mode 100644 index 00000000..b64250b5 --- /dev/null +++ b/src/main/java/org/asamk/signal/util/TimeParser.java @@ -0,0 +1,328 @@ +package org.asamk.signal.util; + +import java.time.Clock; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Parses user supplied durations and points in time. + *

+ * Durations: {@code 90m}, {@code 1h37m}, {@code 2 hours 30 minutes}, {@code 1.5h}, {@code 1:30} (h:mm), + * {@code 1:30:00} (h:mm:ss), ISO-8601 ({@code PT2H30M}, {@code P1DT12H}). + *

+ * Points in time: unix timestamps in seconds or milliseconds, ISO-8601 timestamps with or without zone, + * {@code YYYY-MM-DD [HH:MM[:SS]]}, times of day ({@code 18:00}, {@code 6pm}, {@code 6:30pm}, {@code noon}, + * {@code midnight}), optionally prefixed with {@code today} or {@code tomorrow}. + * Times without a date refer to the next occurrence of that time. + */ +public final class TimeParser { + + private static final Pattern DURATION_TOKEN = Pattern.compile("(\\d+(?:[.,]\\d+)?|\\ban?\\b)\\s*([a-zA-Z]+)"); + private static final Pattern DURATION_ONLY = Pattern.compile("^(?:(?:\\d+(?:[.,]\\d+)?|an?)\\s*[a-zA-Z]+\\s*)+$"); + private static final Pattern DURATION_COLON = Pattern.compile("^(\\d+):(\\d{1,2})(?::(\\d{1,2}))?$"); + private static final Pattern UNIX_TIMESTAMP = Pattern.compile("^\\d{9,}$"); + private static final Pattern TIME_OF_DAY = Pattern.compile( + "^(\\d{1,2})(?::(\\d{2}))?(?::(\\d{2}))?\\s*([ap]\\.?m\\.?)?$", + Pattern.CASE_INSENSITIVE); + private static final Pattern DATE_TIME = Pattern.compile( + "^(\\d{4}-\\d{2}-\\d{2})(?:[tT ](\\d{1,2}:\\d{2}(?::\\d{2}(?:\\.\\d{1,9})?)?))?$"); + + private static final Map UNITS = Map.ofEntries(Map.entry("ms", Duration.ofMillis(1)), + Map.entry("msec", Duration.ofMillis(1)), + Map.entry("msecs", Duration.ofMillis(1)), + Map.entry("milli", Duration.ofMillis(1)), + Map.entry("millis", Duration.ofMillis(1)), + Map.entry("millisecond", Duration.ofMillis(1)), + Map.entry("milliseconds", Duration.ofMillis(1)), + Map.entry("s", Duration.ofSeconds(1)), + Map.entry("sec", Duration.ofSeconds(1)), + Map.entry("secs", Duration.ofSeconds(1)), + Map.entry("second", Duration.ofSeconds(1)), + Map.entry("seconds", Duration.ofSeconds(1)), + Map.entry("m", Duration.ofMinutes(1)), + Map.entry("min", Duration.ofMinutes(1)), + Map.entry("mins", Duration.ofMinutes(1)), + Map.entry("minute", Duration.ofMinutes(1)), + Map.entry("minutes", Duration.ofMinutes(1)), + Map.entry("h", Duration.ofHours(1)), + Map.entry("hr", Duration.ofHours(1)), + Map.entry("hrs", Duration.ofHours(1)), + Map.entry("hour", Duration.ofHours(1)), + Map.entry("hours", Duration.ofHours(1)), + Map.entry("d", Duration.ofDays(1)), + Map.entry("day", Duration.ofDays(1)), + Map.entry("days", Duration.ofDays(1)), + Map.entry("w", Duration.ofDays(7)), + Map.entry("wk", Duration.ofDays(7)), + Map.entry("wks", Duration.ofDays(7)), + Map.entry("week", Duration.ofDays(7)), + Map.entry("weeks", Duration.ofDays(7))); + + private TimeParser() { + } + + public static Duration parseDuration(final String input) throws TimeParseException { + var text = normalize(input); + text = stripPrefix(text, "for "); + text = stripPrefix(text, "in "); + if (text.isEmpty()) { + throw new TimeParseException("Duration is empty"); + } + + if (text.startsWith("p")) { + try { + return checkPositive(Duration.parse(text.toUpperCase(Locale.ROOT))); + } catch (DateTimeParseException e) { + throw new TimeParseException("Invalid ISO-8601 duration: " + input); + } + } + + final var colonMatcher = DURATION_COLON.matcher(text); + if (colonMatcher.matches()) { + final var hours = Long.parseLong(colonMatcher.group(1)); + final var minutes = Long.parseLong(colonMatcher.group(2)); + final var seconds = colonMatcher.group(3) == null ? 0 : Long.parseLong(colonMatcher.group(3)); + if (minutes >= 60 || seconds >= 60) { + throw new TimeParseException("Invalid duration: " + input); + } + return checkPositive(Duration.ofHours(hours).plusMinutes(minutes).plusSeconds(seconds)); + } + + if (text.chars().allMatch(Character::isDigit)) { + throw new TimeParseException("Duration \"" + + input + + "\" has no unit, use e.g. \"" + + text + + "m\" for minutes or \"" + + text + + "h\" for hours"); + } + + // Separators between the parts: "2 hours, 30 minutes" / "2 hours and 30 minutes" + text = text.replaceAll("\\band\\b|(?(); + final var days = duration.toDays(); + final var hours = duration.toHoursPart(); + final var minutes = duration.toMinutesPart(); + final var seconds = duration.toSecondsPart(); + if (days > 0) { + parts.add(days + "d"); + } + if (hours > 0) { + parts.add(hours + "h"); + } + if (minutes > 0) { + parts.add(minutes + "m"); + } + if (seconds > 0 || parts.isEmpty()) { + parts.add(seconds + "s"); + } + return String.join(" ", parts); + } + + public static String formatTimestamp(final long timestamp, final ZoneId zone) { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(Instant.ofEpochMilli(timestamp).atZone(zone)); + } + + private static LocalTime parseTimeOfDay(final String text, final String input) throws TimeParseException { + switch (text) { + case "noon", "midday" -> { + return LocalTime.NOON; + } + case "midnight" -> { + return LocalTime.MIDNIGHT; + } + default -> { + } + } + final var matcher = TIME_OF_DAY.matcher(text); + if (!matcher.matches()) { + throw new TimeParseException("Invalid time: " + input); + } + var hour = Integer.parseInt(matcher.group(1)); + final var minute = matcher.group(2) == null ? 0 : Integer.parseInt(matcher.group(2)); + final var second = matcher.group(3) == null ? 0 : Integer.parseInt(matcher.group(3)); + final var meridiem = matcher.group(4); + if (meridiem != null) { + if (hour < 1 || hour > 12) { + throw new TimeParseException("Invalid 12-hour time: " + input); + } + final var pm = meridiem.toLowerCase(Locale.ROOT).startsWith("p"); + if (hour == 12) { + hour = pm ? 12 : 0; + } else if (pm) { + hour += 12; + } + } else if (matcher.group(2) == null) { + // A bare number without minutes or am/pm is too ambiguous to be a time + throw new TimeParseException("Invalid time: " + input); + } + try { + return LocalTime.of(hour, minute, second); + } catch (DateTimeException e) { + throw new TimeParseException("Invalid time: " + input); + } + } + + private static String padHour(final String time) { + return time.indexOf(':') == 1 ? "0" + time : time; + } + + private static String normalize(final String input) { + if (input == null) { + return ""; + } + return input.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + private static String stripPrefix(final String text, final String prefix) { + return text.startsWith(prefix) ? text.substring(prefix.length()).trim() : text; + } + + private static Duration checkPositive(final Duration duration) throws TimeParseException { + if (duration.isZero() || duration.isNegative()) { + throw new TimeParseException("Duration must be positive"); + } + return duration; + } + + public static final class TimeParseException extends Exception { + + public TimeParseException(final String message) { + super(message); + } + } +} diff --git a/src/test/java/org/asamk/signal/util/TimeParserTest.java b/src/test/java/org/asamk/signal/util/TimeParserTest.java new file mode 100644 index 00000000..a598cfd3 --- /dev/null +++ b/src/test/java/org/asamk/signal/util/TimeParserTest.java @@ -0,0 +1,170 @@ +package org.asamk.signal.util; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TimeParserTest { + + private static final ZoneId ZONE = ZoneId.of("Europe/Berlin"); + // Thursday 2026-09-03 14:30:00 in Berlin (CEST, UTC+2) + private static final ZonedDateTime NOW = ZonedDateTime.of(2026, 9, 3, 14, 30, 0, 0, ZONE); + private static final Clock CLOCK = Clock.fixed(NOW.toInstant(), ZONE); + + private static Duration duration(String input) throws TimeParser.TimeParseException { + return TimeParser.parseDuration(input); + } + + private static ZonedDateTime timestamp(String input) throws TimeParser.TimeParseException { + return Instant.ofEpochMilli(TimeParser.parseTimestamp(input, CLOCK)).atZone(ZONE); + } + + @Test + void parsesCompactDurations() throws Exception { + assertEquals(Duration.ofMinutes(90), duration("90m")); + assertEquals(Duration.ofMinutes(97), duration("1h37m")); + assertEquals(Duration.ofMinutes(97), duration("1h 37m")); + assertEquals(Duration.ofHours(2), duration("2h")); + assertEquals(Duration.ofDays(1).plusHours(2).plusMinutes(30).plusSeconds(15), duration("1d 2h 30m 15s")); + assertEquals(Duration.ofDays(14), duration("2w")); + assertEquals(Duration.ofMillis(1500), duration("1500ms")); + } + + @Test + void parsesWordyDurations() throws Exception { + assertEquals(Duration.ofHours(2), duration("2 hours")); + assertEquals(Duration.ofMinutes(150), duration("2 hours 30 minutes")); + assertEquals(Duration.ofMinutes(150), duration("2 hours, 30 minutes")); + assertEquals(Duration.ofMinutes(150), duration("2 hours and 30 minutes")); + assertEquals(Duration.ofHours(1), duration("an hour")); + assertEquals(Duration.ofMinutes(1), duration("a minute")); + assertEquals(Duration.ofHours(3), duration("for 3 hrs")); + assertEquals(Duration.ofMinutes(45), duration(" 45 MINUTES ")); + } + + @Test + void parsesFractionalDurations() throws Exception { + assertEquals(Duration.ofMinutes(90), duration("1.5h")); + assertEquals(Duration.ofMinutes(90), duration("1,5 hours")); + assertEquals(Duration.ofSeconds(30), duration("0.5m")); + } + + @Test + void parsesColonAndIsoDurations() throws Exception { + assertEquals(Duration.ofMinutes(90), duration("1:30")); + assertEquals(Duration.ofHours(1).plusMinutes(30).plusSeconds(10), duration("1:30:10")); + assertEquals(Duration.ofMinutes(150), duration("PT2H30M")); + assertEquals(Duration.ofMinutes(150), duration("pt2h30m")); + assertEquals(Duration.ofDays(1).plusHours(12), duration("P1DT12H")); + } + + @Test + void rejectsInvalidDurations() { + assertThrows(TimeParser.TimeParseException.class, () -> duration("90")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("0m")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("2 fortnights")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("1:75")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("18:00pm")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("PT")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("soon")); + } + + @Test + void parsesUnixTimestamps() throws Exception { + assertEquals(1_780_000_000_000L, TimeParser.parseTimestamp("1780000000", CLOCK)); + assertEquals(1_780_000_000_123L, TimeParser.parseTimestamp("1780000000123", CLOCK)); + } + + @Test + void parsesIsoTimestamps() throws Exception { + assertEquals(Instant.parse("2026-09-03T18:00:00Z").toEpochMilli(), + TimeParser.parseTimestamp("2026-09-03T18:00:00Z", CLOCK)); + assertEquals(Instant.parse("2026-09-03T18:00:00.500Z").toEpochMilli(), + TimeParser.parseTimestamp("2026-09-03T18:00:00.500Z", CLOCK)); + assertEquals(Instant.parse("2026-09-03T16:00:00Z").toEpochMilli(), + TimeParser.parseTimestamp("2026-09-03T18:00:00+02:00", CLOCK)); + assertEquals(Instant.parse("2026-09-03T16:00:00Z").toEpochMilli(), + TimeParser.parseTimestamp("2026-09-03T18:00:00+02:00[Europe/Berlin]", CLOCK)); + } + + @Test + void parsesLocalDateTimes() throws Exception { + assertEquals(NOW.withHour(18).withMinute(0), timestamp("2026-09-03T18:00")); + assertEquals(NOW.withHour(18).withMinute(0), timestamp("2026-09-03 18:00")); + assertEquals(NOW.withHour(18).withMinute(0).withSecond(30), timestamp("2026-09-03 18:00:30")); + assertEquals(NOW.withHour(9).withMinute(5), timestamp("2026-09-03 9:05")); + assertEquals(NOW.plusDays(2).withHour(0).withMinute(0), timestamp("2026-09-05")); + } + + @Test + void parsesTimesOfDayAsNextOccurrence() throws Exception { + // later today + assertEquals(NOW.withHour(18).withMinute(0), timestamp("18:00")); + assertEquals(NOW.withHour(18).withMinute(0), timestamp("6pm")); + assertEquals(NOW.withHour(18).withMinute(30), timestamp("6:30pm")); + assertEquals(NOW.withHour(18).withMinute(30), timestamp("6:30 PM")); + assertEquals(NOW.withHour(18).withMinute(30), timestamp("6:30 p.m.")); + assertEquals(NOW.withHour(14).withMinute(45).withSecond(10), timestamp("14:45:10")); + assertEquals(NOW.withHour(18).withMinute(0), timestamp("until 18:00")); + assertEquals(NOW.withHour(18).withMinute(0), timestamp("at 18:00")); + // already passed today, so tomorrow + assertEquals(NOW.plusDays(1).withHour(9).withMinute(0), timestamp("9:00")); + assertEquals(NOW.plusDays(1).withHour(9).withMinute(0), timestamp("9am")); + assertEquals(NOW.plusDays(1).withHour(12).withMinute(0), timestamp("noon")); + assertEquals(NOW.plusDays(1).withHour(0).withMinute(0), timestamp("midnight")); + assertEquals(NOW.plusDays(1).withHour(0).withMinute(0), timestamp("12am")); + assertEquals(NOW.plusDays(1).withHour(12).withMinute(0), timestamp("12pm")); + // exactly now counts as passed + assertEquals(NOW.plusDays(1), timestamp("14:30")); + } + + @Test + void parsesRelativeDays() throws Exception { + assertEquals(NOW.plusDays(1).withHour(0).withMinute(0), timestamp("tomorrow")); + assertEquals(NOW.plusDays(1).withHour(9).withMinute(0), timestamp("tomorrow 9:00")); + assertEquals(NOW.plusDays(1).withHour(9).withMinute(0), timestamp("tomorrow at 9am")); + assertEquals(NOW.plusDays(1).withHour(12).withMinute(0), timestamp("tomorrow noon")); + // "today" is explicit and doesn't roll over + assertEquals(NOW.withHour(9).withMinute(0), timestamp("today 9:00")); + } + + @Test + void rejectsInvalidTimestamps() { + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("")); + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("6")); + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("25:00")); + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("13pm")); + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("2026-13-01")); + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("90m")); + assertThrows(TimeParser.TimeParseException.class, () -> timestamp("later")); + } + + @Test + void detectsDurations() { + assertTrue(TimeParser.looksLikeDuration("90m")); + assertTrue(TimeParser.looksLikeDuration("2 hours")); + assertTrue(TimeParser.looksLikeDuration("2 hours and 30 minutes")); + assertFalse(TimeParser.looksLikeDuration("tomorrow")); + assertFalse(TimeParser.looksLikeDuration("6pm")); + assertFalse(TimeParser.looksLikeDuration("1780000000")); + } + + @Test + void formatsDurations() { + assertEquals("2h 30m", TimeParser.formatDuration(Duration.ofMinutes(150))); + assertEquals("1d 2h 30m 15s", TimeParser.formatDuration(Duration.ofDays(1).plusHours(2).plusMinutes(30).plusSeconds(15))); + assertEquals("0s", TimeParser.formatDuration(Duration.ZERO)); + assertEquals("45s", TimeParser.formatDuration(Duration.ofMillis(45_999))); + assertEquals("-5m", TimeParser.formatDuration(Duration.ofMinutes(-5))); + } +} From 0c08d120c70fadb1d225ef905be17baa1c096353 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:14:24 -0400 Subject: [PATCH 3/3] Avoid regex backtracking in duration parsing Consume the duration text token by token instead of validating the whole string with a nested-quantifier regex, which could backtrack exponentially on crafted input. --- .../java/org/asamk/signal/util/TimeParser.java | 16 +++++++++++----- .../org/asamk/signal/util/TimeParserTest.java | 5 +++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/asamk/signal/util/TimeParser.java b/src/main/java/org/asamk/signal/util/TimeParser.java index b64250b5..146f9856 100644 --- a/src/main/java/org/asamk/signal/util/TimeParser.java +++ b/src/main/java/org/asamk/signal/util/TimeParser.java @@ -31,7 +31,6 @@ import java.util.regex.Pattern; public final class TimeParser { private static final Pattern DURATION_TOKEN = Pattern.compile("(\\d+(?:[.,]\\d+)?|\\ban?\\b)\\s*([a-zA-Z]+)"); - private static final Pattern DURATION_ONLY = Pattern.compile("^(?:(?:\\d+(?:[.,]\\d+)?|an?)\\s*[a-zA-Z]+\\s*)+$"); private static final Pattern DURATION_COLON = Pattern.compile("^(\\d+):(\\d{1,2})(?::(\\d{1,2}))?$"); private static final Pattern UNIX_TIMESTAMP = Pattern.compile("^\\d{9,}$"); private static final Pattern TIME_OF_DAY = Pattern.compile( @@ -113,12 +112,19 @@ public final class TimeParser { // Separators between the parts: "2 hours, 30 minutes" / "2 hours and 30 minutes" text = text.replaceAll("\\band\\b|(? duration("18:00pm")); assertThrows(TimeParser.TimeParseException.class, () -> duration("PT")); assertThrows(TimeParser.TimeParseException.class, () -> duration("soon")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("2h xyz")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("2h 30")); + assertThrows(TimeParser.TimeParseException.class, () -> duration("hours 2")); + // Must fail fast, without regex backtracking blowing up + assertThrows(TimeParser.TimeParseException.class, () -> duration("a" + "Aa".repeat(5000))); } @Test