diff --git a/CHANGELOG.md b/CHANGELOG.md index b091909f..55d2b8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 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 - `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded ## [0.14.8] - 2026-09-10 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/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/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 1b0ed529..e6a6af07 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/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/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/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/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 26fac479..0585aba5 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..146f9856 --- /dev/null +++ b/src/main/java/org/asamk/signal/util/TimeParser.java @@ -0,0 +1,334 @@ +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_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/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 60ff4c40..afcd949b 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 @@ -9916,6 +9916,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" }, @@ -10049,6 +10072,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" }, @@ -10064,6 +10140,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": [ 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..b9934b5a --- /dev/null +++ b/src/test/java/org/asamk/signal/util/TimeParserTest.java @@ -0,0 +1,175 @@ +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")); + 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 + 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))); + } +}