mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-23 06:39:04 +00:00
Merge ad827fbd0aae7a498293ebdb948144158a2e8345 into e1d7bc5587ea0d82a9ac9ec27fa88464f2dde751
This commit is contained in:
commit
908a6d11be
@ -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
|
||||
|
||||
@ -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<NotificationProfile> 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.
|
||||
|
||||
@ -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<String> emoji,
|
||||
int color,
|
||||
long createdAt,
|
||||
boolean allowAllCalls,
|
||||
boolean allowAllMentions,
|
||||
List<RecipientIdentifier> allowedMembers,
|
||||
boolean scheduleEnabled,
|
||||
int scheduleStart,
|
||||
int scheduleEnd,
|
||||
List<DayOfWeek> scheduleDaysEnabled
|
||||
) {}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
public class NotificationProfileNotFoundException extends Exception {
|
||||
|
||||
public NotificationProfileNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
/**
|
||||
* The manual override state for notification profiles, shared between all devices of an account.
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
@ -7,12 +7,14 @@ import org.asamk.signal.manager.api.Profile;
|
||||
import org.asamk.signal.manager.api.StickerPackId;
|
||||
import org.asamk.signal.manager.internal.SignalDependencies;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientId;
|
||||
import org.asamk.signal.manager.storage.stickers.StickerPack;
|
||||
import org.asamk.signal.manager.syncStorage.AccountRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.NotificationProfileRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor;
|
||||
import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector;
|
||||
import org.asamk.signal.manager.syncStorage.StorageSyncModels;
|
||||
@ -58,7 +60,8 @@ public class StorageHelper {
|
||||
ManifestRecord.Identifier.Type.GROUPV1.getValue(),
|
||||
ManifestRecord.Identifier.Type.GROUPV2.getValue(),
|
||||
ManifestRecord.Identifier.Type.ACCOUNT.getValue(),
|
||||
ManifestRecord.Identifier.Type.STICKER_PACK.getValue());
|
||||
ManifestRecord.Identifier.Type.STICKER_PACK.getValue(),
|
||||
ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue());
|
||||
|
||||
private final SignalAccount account;
|
||||
private final SignalDependencies dependencies;
|
||||
@ -134,6 +137,7 @@ public class StorageHelper {
|
||||
account.getRecipientStore().setMissingStorageIds();
|
||||
account.getGroupStore().setMissingStorageIds();
|
||||
account.getStickerStore().setMissingStorageIds();
|
||||
account.getNotificationProfileStore().setMissingStorageIds();
|
||||
|
||||
var needsMultiDeviceSync = false;
|
||||
|
||||
@ -245,12 +249,15 @@ public class StorageHelper {
|
||||
oldUnregisteredLocalOnlyIds);
|
||||
final var updatedStickers = account.getStickerStore()
|
||||
.removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds);
|
||||
final var updatedNotificationProfiles = account.getNotificationProfileStore()
|
||||
.removeLocalOnlyDeletedNotificationProfiles(connection, oldUnregisteredLocalOnlyIds);
|
||||
|
||||
if (updated > 0 || updatedStickers > 0) {
|
||||
if (updated > 0 || updatedStickers > 0 || updatedNotificationProfiles > 0) {
|
||||
logger.warn(
|
||||
"Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.",
|
||||
"Found {} recipients, {} sticker packs and {} notification profiles that were deleted remotely but only marked deleted locally. Removed those from local store.",
|
||||
updated,
|
||||
updatedStickers);
|
||||
updatedStickers,
|
||||
updatedNotificationProfiles);
|
||||
}
|
||||
}
|
||||
|
||||
@ -436,6 +443,7 @@ public class StorageHelper {
|
||||
final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
|
||||
final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
|
||||
final Map<StickerPackId, StorageId> newStickerPackStorageIds;
|
||||
final Map<Long, StorageId> newNotificationProfileStorageIds;
|
||||
|
||||
try (final var connection = account.getAccountDatabase().getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
@ -495,6 +503,16 @@ public class StorageHelper {
|
||||
new StorageRecord.Builder().stickerPack(record).build()));
|
||||
}
|
||||
|
||||
final var notificationProfiles = account.getNotificationProfileStore()
|
||||
.getNotificationProfiles(connection);
|
||||
newNotificationProfileStorageIds = generateNotificationProfileStorageIds(notificationProfiles);
|
||||
for (final var notificationProfile : notificationProfiles) {
|
||||
final var storageId = newNotificationProfileStorageIds.get(notificationProfile.internalId());
|
||||
final var record = StorageSyncModels.localToRemoteRecord(notificationProfile);
|
||||
newStorageRecords.add(new SignalStorageRecord(storageId,
|
||||
new StorageRecord.Builder().notificationProfile(record).build()));
|
||||
}
|
||||
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed to sync remote storage", e);
|
||||
@ -546,6 +564,7 @@ public class StorageHelper {
|
||||
account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
|
||||
account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
|
||||
account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds);
|
||||
account.getNotificationProfileStore().updateStorageIds(connection, newNotificationProfileStorageIds);
|
||||
|
||||
// delete all unknown storage ids
|
||||
account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
|
||||
@ -586,6 +605,14 @@ public class StorageHelper {
|
||||
_ -> StorageId.forStickerPack(KeyUtils.createRawStorageId())));
|
||||
}
|
||||
|
||||
private Map<Long, StorageId> generateNotificationProfileStorageIds(
|
||||
final List<NotificationProfile> notificationProfiles
|
||||
) {
|
||||
return notificationProfiles.stream()
|
||||
.collect(Collectors.toMap(NotificationProfile::internalId,
|
||||
_ -> StorageId.forNotificationProfile(KeyUtils.createRawStorageId())));
|
||||
}
|
||||
|
||||
private void storeManifestLocally(
|
||||
final SignalStorageManifest remoteManifest
|
||||
) {
|
||||
@ -626,6 +653,7 @@ public class StorageHelper {
|
||||
storageIds.addAll(account.getGroupStore().getStorageIds(connection));
|
||||
storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
|
||||
storageIds.addAll(account.getStickerStore().getStorageIds(connection));
|
||||
storageIds.addAll(account.getNotificationProfileStore().getStorageIds(connection));
|
||||
storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
|
||||
return storageIds;
|
||||
}
|
||||
@ -682,6 +710,16 @@ public class StorageHelper {
|
||||
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
||||
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build());
|
||||
}
|
||||
case ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> {
|
||||
final var notificationProfile = account.getNotificationProfileStore()
|
||||
.getNotificationProfile(connection, storageId);
|
||||
if (notificationProfile == null) {
|
||||
throw new AssertionError("Missing local notification profile model for storage id: " + storageId);
|
||||
}
|
||||
final var record = StorageSyncModels.localToRemoteRecord(notificationProfile);
|
||||
yield new SignalStorageRecord(storageId,
|
||||
new StorageRecord.Builder().notificationProfile(record).build());
|
||||
}
|
||||
case null, default -> {
|
||||
throw new AssertionError("Got unknown local storage record type: " + storageId);
|
||||
}
|
||||
@ -752,6 +790,7 @@ public class StorageHelper {
|
||||
context.getJobExecutor(),
|
||||
identityConflictsPendingRepair);
|
||||
final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection);
|
||||
final var notificationProfileRecordProcessor = new NotificationProfileRecordProcessor(account, connection);
|
||||
|
||||
final var contactRecords = records.stream()
|
||||
.filter(record -> record.getProto().contact != null)
|
||||
@ -781,6 +820,11 @@ public class StorageHelper {
|
||||
logger.debug("Reading record {} of type stickerPack", record.getId());
|
||||
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
|
||||
record.getId()));
|
||||
} else if (record.getProto().notificationProfile != null) {
|
||||
logger.debug("Reading record {} of type notificationProfile", record.getId());
|
||||
notificationProfileRecordProcessor.process(StorageRecordConvertersKt.toSignalNotificationProfileRecord(
|
||||
record.getProto().notificationProfile,
|
||||
record.getId()));
|
||||
} else {
|
||||
unknownRecords.add(record.getId());
|
||||
}
|
||||
@ -790,6 +834,7 @@ public class StorageHelper {
|
||||
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
|
||||
processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
|
||||
processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds());
|
||||
processedRecords.addAll(notificationProfileRecordProcessor.getUpdatedStorageIds());
|
||||
|
||||
return new Pair<>(unknownRecords, processedRecords);
|
||||
}
|
||||
|
||||
@ -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<NotificationProfile> getNotificationProfiles() {
|
||||
final var profiles = new ArrayList<NotificationProfile>();
|
||||
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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -0,0 +1,244 @@
|
||||
package org.asamk.signal.manager.storage.notificationProfiles;
|
||||
|
||||
import org.asamk.signal.manager.storage.Database;
|
||||
import org.asamk.signal.manager.storage.Utils;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class NotificationProfileStore {
|
||||
|
||||
private static final String TABLE_NOTIFICATION_PROFILE = "notification_profile";
|
||||
|
||||
private final Database database;
|
||||
|
||||
public static void createSql(Connection connection) throws SQLException {
|
||||
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
|
||||
try (final var statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE notification_profile (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
profile_id BLOB UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
deleted_timestamp INTEGER NOT NULL DEFAULT 0,
|
||||
storage_id BLOB UNIQUE,
|
||||
storage_record BLOB
|
||||
) STRICT;
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
public NotificationProfileStore(final Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public List<NotificationProfile> getNotificationProfiles() {
|
||||
try (final var connection = database.getConnection()) {
|
||||
return getNotificationProfiles(connection);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from notification profile store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<NotificationProfile> getNotificationProfiles(final Connection connection) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record
|
||||
FROM %s n
|
||||
ORDER BY n._id
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
return Utils.executeQueryForStream(statement, this::getNotificationProfileFromResultSet).toList();
|
||||
}
|
||||
}
|
||||
|
||||
public NotificationProfile getNotificationProfile(final Connection connection, final byte[] profileId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record
|
||||
FROM %s n
|
||||
WHERE n.profile_id = ?
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, profileId);
|
||||
return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
public NotificationProfile getNotificationProfile(final Connection connection, final StorageId storageId) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record
|
||||
FROM %s n
|
||||
WHERE n.storage_id = ?
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
public List<StorageId> getStorageIds(final Connection connection) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT n.storage_id
|
||||
FROM %s n
|
||||
WHERE n.storage_id IS NOT NULL
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateStorageId(
|
||||
final Connection connection,
|
||||
final byte[] profileId,
|
||||
final StorageId storageId
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?
|
||||
WHERE profile_id = ?
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
statement.setBytes(2, profileId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateStorageIds(
|
||||
final Connection connection,
|
||||
final Map<Long, StorageId> storageIdMap
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
for (final var entry : storageIdMap.entrySet()) {
|
||||
statement.setBytes(1, entry.getValue().getRaw());
|
||||
statement.setLong(2, entry.getKey());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setMissingStorageIds() {
|
||||
final var selectSql = (
|
||||
"""
|
||||
SELECT n._id
|
||||
FROM %s n
|
||||
WHERE n.storage_id IS NULL
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
final var updateSql = (
|
||||
"""
|
||||
UPDATE %s
|
||||
SET storage_id = ?
|
||||
WHERE _id = ?
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (final var selectStatement = connection.prepareStatement(selectSql)) {
|
||||
final var ids = Utils.executeQueryForStream(selectStatement, Utils::getIdMapper).toList();
|
||||
try (final var updateStatement = connection.prepareStatement(updateSql)) {
|
||||
for (final var id : ids) {
|
||||
updateStatement.setBytes(1, KeyUtils.createRawStorageId());
|
||||
updateStatement.setLong(2, id);
|
||||
updateStatement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update notification profile store", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove profiles that were deleted remotely and are only kept locally as deletion tombstones.
|
||||
*/
|
||||
public int removeLocalOnlyDeletedNotificationProfiles(
|
||||
final Connection connection,
|
||||
final Collection<StorageId> storageIds
|
||||
) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
DELETE FROM %s
|
||||
WHERE storage_id = ? AND deleted_timestamp > 0
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
var count = 0;
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
for (final var storageId : storageIds) {
|
||||
statement.setBytes(1, storageId.getRaw());
|
||||
count += statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void upsertFromStorageSync(
|
||||
final Connection connection,
|
||||
final SignalNotificationProfileRecord record
|
||||
) throws SQLException {
|
||||
final var remote = record.getProto();
|
||||
final var profileId = remote.id.toByteArray();
|
||||
final var storageRecord = remote.encode();
|
||||
|
||||
final var sql = (
|
||||
"""
|
||||
INSERT INTO %s (profile_id, name, deleted_timestamp, storage_id, storage_record)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (profile_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
deleted_timestamp = excluded.deleted_timestamp,
|
||||
storage_id = excluded.storage_id,
|
||||
storage_record = excluded.storage_record
|
||||
"""
|
||||
).formatted(TABLE_NOTIFICATION_PROFILE);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, profileId);
|
||||
statement.setString(2, remote.name);
|
||||
statement.setLong(3, remote.deletedAtTimestampMs);
|
||||
statement.setBytes(4, record.getId().getRaw());
|
||||
statement.setBytes(5, storageRecord);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationProfile getNotificationProfileFromResultSet(ResultSet resultSet) throws SQLException {
|
||||
final var internalId = resultSet.getLong("_id");
|
||||
final var profileId = resultSet.getBytes("profile_id");
|
||||
final var name = resultSet.getString("name");
|
||||
final var deletedTimestamp = resultSet.getLong("deleted_timestamp");
|
||||
final var storageIdBytes = resultSet.getBytes("storage_id");
|
||||
final var storageId = storageIdBytes == null ? null : StorageId.forNotificationProfile(storageIdBytes);
|
||||
final var storageRecord = resultSet.getBytes("storage_record");
|
||||
return new NotificationProfile(internalId, profileId, name, deletedTimestamp, storageId, storageRecord);
|
||||
}
|
||||
|
||||
private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException {
|
||||
final var storageId = resultSet.getBytes("storage_id");
|
||||
return StorageId.forNotificationProfile(storageId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package org.asamk.signal.manager.syncStorage;
|
||||
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Optional;
|
||||
|
||||
public class NotificationProfileRecordProcessor extends DefaultStorageRecordProcessor<SignalNotificationProfileRecord> {
|
||||
|
||||
private final SignalAccount account;
|
||||
private final Connection connection;
|
||||
|
||||
public NotificationProfileRecordProcessor(final SignalAccount account, final Connection connection) {
|
||||
this.account = account;
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(final SignalNotificationProfileRecord lhs, final SignalNotificationProfileRecord rhs) {
|
||||
return lhs.getProto().id.equals(rhs.getProto().id) ? 0 : 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isInvalid(final SignalNotificationProfileRecord remote) {
|
||||
return remote.getProto().id.size() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Optional<SignalNotificationProfileRecord> getMatching(final SignalNotificationProfileRecord remote) throws SQLException {
|
||||
final var profileId = remote.getProto().id.toByteArray();
|
||||
final var local = account.getNotificationProfileStore().getNotificationProfile(connection, profileId);
|
||||
|
||||
if (local == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final StorageId storageId;
|
||||
if (local.storageId() != null) {
|
||||
storageId = local.storageId();
|
||||
} else {
|
||||
storageId = StorageId.forNotificationProfile(KeyUtils.createRawStorageId());
|
||||
account.getNotificationProfileStore().updateStorageId(connection, profileId, storageId);
|
||||
}
|
||||
|
||||
return Optional.of(new SignalNotificationProfileRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SignalNotificationProfileRecord merge(
|
||||
final SignalNotificationProfileRecord remoteRecord,
|
||||
final SignalNotificationProfileRecord localRecord
|
||||
) {
|
||||
final var remote = remoteRecord.getProto();
|
||||
final var local = localRecord.getProto();
|
||||
|
||||
// Profiles are only ever edited by the official clients, so remote wins.
|
||||
// The one exception is an older local deletion, which must not be resurrected.
|
||||
if (StickerPackRecordProcessor.shouldKeepLocalDeletion(remote.deletedAtTimestampMs,
|
||||
local.deletedAtTimestampMs)) {
|
||||
return localRecord;
|
||||
}
|
||||
|
||||
return remoteRecord;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void insertLocal(final SignalNotificationProfileRecord record) throws SQLException {
|
||||
account.getNotificationProfileStore().upsertFromStorageSync(connection, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateLocal(final StorageRecordUpdate<SignalNotificationProfileRecord> update) throws SQLException {
|
||||
account.getNotificationProfileStore().upsertFromStorageSync(connection, update.newRecord());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<byte[]> 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<RecipientIdentifier> toRecipientIdentifiers(final List<Recipient> recipients) {
|
||||
final var result = new ArrayList<RecipientIdentifier>();
|
||||
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<DayOfWeek> 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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<NotificationProfile> 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<JsonNotificationProfile> 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<String> allowedMembers,
|
||||
boolean scheduleEnabled,
|
||||
String scheduleStart,
|
||||
String scheduleEnd,
|
||||
List<DayOfWeek> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<NotificationProfile> 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<String, Object> 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<String, Object>();
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<NotificationProfile> 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();
|
||||
|
||||
334
src/main/java/org/asamk/signal/util/TimeParser.java
Normal file
334
src/main/java/org/asamk/signal/util/TimeParser.java
Normal file
@ -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.
|
||||
* <p>
|
||||
* 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}).
|
||||
* <p>
|
||||
* 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<String, Duration> 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|(?<!\\d),|,(?!\\d)", " ").replaceAll("\\s+", " ").trim();
|
||||
// Consume the text token by token, so that everything has to be part of an amount/unit pair
|
||||
final var matcher = DURATION_TOKEN.matcher(text);
|
||||
var result = Duration.ZERO;
|
||||
var position = 0;
|
||||
while (position < text.length()) {
|
||||
matcher.region(position, text.length());
|
||||
if (!matcher.lookingAt()) {
|
||||
throw new TimeParseException("Invalid duration: " + input);
|
||||
}
|
||||
position = matcher.end();
|
||||
while (position < text.length() && text.charAt(position) == ' ') {
|
||||
position++;
|
||||
}
|
||||
final var amountText = matcher.group(1);
|
||||
final var unitText = matcher.group(2).toLowerCase(Locale.ROOT);
|
||||
final var unit = UNITS.get(unitText);
|
||||
if (unit == null) {
|
||||
throw new TimeParseException("Unknown duration unit \"" + unitText + "\" in: " + input);
|
||||
}
|
||||
final double amount = amountText.equals("a") || amountText.equals("an")
|
||||
? 1
|
||||
: Double.parseDouble(amountText.replace(',', '.'));
|
||||
result = result.plusMillis(Math.round(amount * unit.toMillis()));
|
||||
}
|
||||
return checkPositive(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a point in time, returning it as milliseconds since epoch.
|
||||
*/
|
||||
public static long parseTimestamp(final String input, final Clock clock) throws TimeParseException {
|
||||
var text = normalize(input);
|
||||
text = stripPrefix(text, "until ");
|
||||
text = stripPrefix(text, "at ");
|
||||
text = stripPrefix(text, "@");
|
||||
if (text.isEmpty()) {
|
||||
throw new TimeParseException("Timestamp is empty");
|
||||
}
|
||||
final var zone = clock.getZone();
|
||||
final var now = ZonedDateTime.now(clock);
|
||||
|
||||
if (UNIX_TIMESTAMP.matcher(text).matches()) {
|
||||
final long value;
|
||||
try {
|
||||
value = Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new TimeParseException("Invalid unix timestamp: " + input);
|
||||
}
|
||||
// Anything below 1e11 is in seconds (that's the year 5138), above it's milliseconds
|
||||
return value < 100_000_000_000L ? value * 1000 : value;
|
||||
}
|
||||
|
||||
try {
|
||||
return Instant.parse(input.trim()).toEpochMilli();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
try {
|
||||
return OffsetDateTime.parse(input.trim()).toInstant().toEpochMilli();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
try {
|
||||
return ZonedDateTime.parse(input.trim()).toInstant().toEpochMilli();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
|
||||
final var dateTimeMatcher = DATE_TIME.matcher(text);
|
||||
if (dateTimeMatcher.matches()) {
|
||||
try {
|
||||
final var date = LocalDate.parse(dateTimeMatcher.group(1));
|
||||
final var time = dateTimeMatcher.group(2) == null
|
||||
? LocalTime.MIDNIGHT
|
||||
: LocalTime.parse(padHour(dateTimeMatcher.group(2)));
|
||||
return LocalDateTime.of(date, time).atZone(zone).toInstant().toEpochMilli();
|
||||
} catch (DateTimeException e) {
|
||||
throw new TimeParseException("Invalid date/time: " + input);
|
||||
}
|
||||
}
|
||||
|
||||
var dayOffset = 0;
|
||||
var explicitDay = false;
|
||||
if (text.startsWith("tomorrow")) {
|
||||
dayOffset = 1;
|
||||
explicitDay = true;
|
||||
text = text.substring("tomorrow".length()).trim();
|
||||
text = stripPrefix(text, "at ");
|
||||
} else if (text.startsWith("today")) {
|
||||
explicitDay = true;
|
||||
text = text.substring("today".length()).trim();
|
||||
text = stripPrefix(text, "at ");
|
||||
}
|
||||
final var day = now.toLocalDate().plusDays(dayOffset);
|
||||
|
||||
final LocalTime time;
|
||||
if (text.isEmpty()) {
|
||||
if (!explicitDay) {
|
||||
throw new TimeParseException("Invalid timestamp: " + input);
|
||||
}
|
||||
time = LocalTime.MIDNIGHT;
|
||||
} else {
|
||||
time = parseTimeOfDay(text, input);
|
||||
}
|
||||
|
||||
var result = ZonedDateTime.of(day, time, zone);
|
||||
if (!explicitDay && !result.isAfter(now)) {
|
||||
// A bare time of day refers to the next occurrence
|
||||
result = result.plusDays(1);
|
||||
}
|
||||
return result.toInstant().toEpochMilli();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the input looks like a duration rather than a point in time.
|
||||
* Used to give a helpful error message when the user mixes the two up.
|
||||
*/
|
||||
public static boolean looksLikeDuration(final String input) {
|
||||
try {
|
||||
parseDuration(input);
|
||||
return true;
|
||||
} catch (TimeParseException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static String formatDuration(final Duration duration) {
|
||||
if (duration.isNegative()) {
|
||||
return "-" + formatDuration(duration.negated());
|
||||
}
|
||||
final var parts = new ArrayList<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": [
|
||||
|
||||
175
src/test/java/org/asamk/signal/util/TimeParserTest.java
Normal file
175
src/test/java/org/asamk/signal/util/TimeParserTest.java
Normal file
@ -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)));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user