diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a4c0ce..5cee7a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- Sync notification profiles via storage sync +- New `listNotificationProfiles` command to show notification profiles and the current manual override +- New `setNotificationProfile` command to manually turn a notification profile on for any duration or until any point in time, or off + ## [0.14.8] - 2026-09-10 ### Added diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 7d0c73af..c6c39b19 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -30,6 +30,9 @@ import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NotAGroupMemberException; import org.asamk.signal.manager.api.NotPrimaryDeviceException; +import org.asamk.signal.manager.api.NotificationProfile; +import org.asamk.signal.manager.api.NotificationProfileNotFoundException; +import org.asamk.signal.manager.api.NotificationProfileOverride; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.PendingAdminApprovalException; import org.asamk.signal.manager.api.PinLockMissingException; @@ -120,6 +123,23 @@ public interface Manager extends Closeable { void updateConfiguration(Configuration configuration) throws NotPrimaryDeviceException; + /** + * Get the notification profiles known for this account. + * Profiles are created and edited in the official clients and received via storage sync. + */ + List getNotificationProfiles(); + + /** + * Get the current manual notification profile override (manually enabled or disabled profile). + */ + NotificationProfileOverride getNotificationProfileOverride(); + + /** + * Manually enable or disable a notification profile, or clear the manual override. + * The change is synced to all devices via storage service. + */ + void setNotificationProfileOverride(NotificationProfileOverride override) throws NotificationProfileNotFoundException; + /** * Update the user's profile. * If a field is null, the previous value will be kept. diff --git a/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java new file mode 100644 index 00000000..699f1e5b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfile.java @@ -0,0 +1,36 @@ +package org.asamk.signal.manager.api; + +import java.time.DayOfWeek; +import java.util.List; +import java.util.Optional; + +/** + * A notification profile, as configured in one of the official clients and synced via storage service. + * + * @param id The raw profile id, unique per account. + * @param name The user visible profile name. + * @param emoji Optional emoji shown next to the name. + * @param color The color as ARGB integer. + * @param createdAt Creation timestamp in milliseconds since epoch. + * @param allowAllCalls If true, calls from everyone are allowed while the profile is active. + * @param allowAllMentions If true, mentions from everyone are allowed while the profile is active. + * @param allowedMembers Contacts and groups whose notifications are still shown while the profile is active. + * @param scheduleEnabled If true, the profile is activated automatically according to the schedule. + * @param scheduleStart Schedule start time as HHMM (e.g. 900 for 9:00, 2230 for 22:30). + * @param scheduleEnd Schedule end time as HHMM. + * @param scheduleDaysEnabled Days of the week the schedule is active on. + */ +public record NotificationProfile( + byte[] id, + String name, + Optional emoji, + int color, + long createdAt, + boolean allowAllCalls, + boolean allowAllMentions, + List allowedMembers, + boolean scheduleEnabled, + int scheduleStart, + int scheduleEnd, + List scheduleDaysEnabled +) {} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java new file mode 100644 index 00000000..e74495f1 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileNotFoundException.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.api; + +public class NotificationProfileNotFoundException extends Exception { + + public NotificationProfileNotFoundException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java new file mode 100644 index 00000000..e19518d3 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/NotificationProfileOverride.java @@ -0,0 +1,42 @@ +package org.asamk.signal.manager.api; + +/** + * The manual override state for notification profiles, shared between all devices of an account. + *

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

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

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