Merge ad827fbd0aae7a498293ebdb948144158a2e8345 into e1d7bc5587ea0d82a9ac9ec27fa88464f2dde751

This commit is contained in:
Site Reliability Enby 2026-09-19 01:52:14 -04:00 committed by GitHub
commit 908a6d11be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2110 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package org.asamk.signal.manager.api;
public class NotificationProfileNotFoundException extends Exception {
public NotificationProfileNotFoundException(String message) {
super(message);
}
}

View File

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

View File

@ -7,12 +7,14 @@ import org.asamk.signal.manager.api.Profile;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.syncStorage.AccountRecordProcessor;
import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
import org.asamk.signal.manager.syncStorage.NotificationProfileRecordProcessor;
import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor;
import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector;
import org.asamk.signal.manager.syncStorage.StorageSyncModels;
@ -58,7 +60,8 @@ public class StorageHelper {
ManifestRecord.Identifier.Type.GROUPV1.getValue(),
ManifestRecord.Identifier.Type.GROUPV2.getValue(),
ManifestRecord.Identifier.Type.ACCOUNT.getValue(),
ManifestRecord.Identifier.Type.STICKER_PACK.getValue());
ManifestRecord.Identifier.Type.STICKER_PACK.getValue(),
ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue());
private final SignalAccount account;
private final SignalDependencies dependencies;
@ -134,6 +137,7 @@ public class StorageHelper {
account.getRecipientStore().setMissingStorageIds();
account.getGroupStore().setMissingStorageIds();
account.getStickerStore().setMissingStorageIds();
account.getNotificationProfileStore().setMissingStorageIds();
var needsMultiDeviceSync = false;
@ -245,12 +249,15 @@ public class StorageHelper {
oldUnregisteredLocalOnlyIds);
final var updatedStickers = account.getStickerStore()
.removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds);
final var updatedNotificationProfiles = account.getNotificationProfileStore()
.removeLocalOnlyDeletedNotificationProfiles(connection, oldUnregisteredLocalOnlyIds);
if (updated > 0 || updatedStickers > 0) {
if (updated > 0 || updatedStickers > 0 || updatedNotificationProfiles > 0) {
logger.warn(
"Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.",
"Found {} recipients, {} sticker packs and {} notification profiles that were deleted remotely but only marked deleted locally. Removed those from local store.",
updated,
updatedStickers);
updatedStickers,
updatedNotificationProfiles);
}
}
@ -436,6 +443,7 @@ public class StorageHelper {
final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
final Map<StickerPackId, StorageId> newStickerPackStorageIds;
final Map<Long, StorageId> newNotificationProfileStorageIds;
try (final var connection = account.getAccountDatabase().getConnection()) {
connection.setAutoCommit(false);
@ -495,6 +503,16 @@ public class StorageHelper {
new StorageRecord.Builder().stickerPack(record).build()));
}
final var notificationProfiles = account.getNotificationProfileStore()
.getNotificationProfiles(connection);
newNotificationProfileStorageIds = generateNotificationProfileStorageIds(notificationProfiles);
for (final var notificationProfile : notificationProfiles) {
final var storageId = newNotificationProfileStorageIds.get(notificationProfile.internalId());
final var record = StorageSyncModels.localToRemoteRecord(notificationProfile);
newStorageRecords.add(new SignalStorageRecord(storageId,
new StorageRecord.Builder().notificationProfile(record).build()));
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed to sync remote storage", e);
@ -546,6 +564,7 @@ public class StorageHelper {
account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds);
account.getNotificationProfileStore().updateStorageIds(connection, newNotificationProfileStorageIds);
// delete all unknown storage ids
account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
@ -586,6 +605,14 @@ public class StorageHelper {
_ -> StorageId.forStickerPack(KeyUtils.createRawStorageId())));
}
private Map<Long, StorageId> generateNotificationProfileStorageIds(
final List<NotificationProfile> notificationProfiles
) {
return notificationProfiles.stream()
.collect(Collectors.toMap(NotificationProfile::internalId,
_ -> StorageId.forNotificationProfile(KeyUtils.createRawStorageId())));
}
private void storeManifestLocally(
final SignalStorageManifest remoteManifest
) {
@ -626,6 +653,7 @@ public class StorageHelper {
storageIds.addAll(account.getGroupStore().getStorageIds(connection));
storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
storageIds.addAll(account.getStickerStore().getStorageIds(connection));
storageIds.addAll(account.getNotificationProfileStore().getStorageIds(connection));
storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
return storageIds;
}
@ -682,6 +710,16 @@ public class StorageHelper {
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build());
}
case ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> {
final var notificationProfile = account.getNotificationProfileStore()
.getNotificationProfile(connection, storageId);
if (notificationProfile == null) {
throw new AssertionError("Missing local notification profile model for storage id: " + storageId);
}
final var record = StorageSyncModels.localToRemoteRecord(notificationProfile);
yield new SignalStorageRecord(storageId,
new StorageRecord.Builder().notificationProfile(record).build());
}
case null, default -> {
throw new AssertionError("Got unknown local storage record type: " + storageId);
}
@ -752,6 +790,7 @@ public class StorageHelper {
context.getJobExecutor(),
identityConflictsPendingRepair);
final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection);
final var notificationProfileRecordProcessor = new NotificationProfileRecordProcessor(account, connection);
final var contactRecords = records.stream()
.filter(record -> record.getProto().contact != null)
@ -781,6 +820,11 @@ public class StorageHelper {
logger.debug("Reading record {} of type stickerPack", record.getId());
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
record.getId()));
} else if (record.getProto().notificationProfile != null) {
logger.debug("Reading record {} of type notificationProfile", record.getId());
notificationProfileRecordProcessor.process(StorageRecordConvertersKt.toSignalNotificationProfileRecord(
record.getProto().notificationProfile,
record.getId()));
} else {
unknownRecords.add(record.getId());
}
@ -790,6 +834,7 @@ public class StorageHelper {
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(notificationProfileRecordProcessor.getUpdatedStorageIds());
return new Pair<>(unknownRecords, processedRecords);
}

View File

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

View File

@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.storage.groups.GroupStore;
import org.asamk.signal.manager.storage.identities.IdentityKeyStore;
import org.asamk.signal.manager.storage.keyValue.KeyValueStore;
import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfileStore;
import org.asamk.signal.manager.storage.prekeys.KyberPreKeyStore;
import org.asamk.signal.manager.storage.prekeys.PreKeyStore;
import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore;
@ -33,7 +34,7 @@ import java.util.UUID;
public class AccountDatabase extends Database {
private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 31;
private static final long DATABASE_VERSION = 32;
private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource);
@ -59,6 +60,7 @@ public class AccountDatabase extends Database {
KeyValueStore.createSql(connection);
CdsiStore.createSql(connection);
UnknownStorageIdStore.createSql(connection);
NotificationProfileStore.createSql(connection);
}
@Override
@ -653,6 +655,21 @@ public class AccountDatabase extends Database {
""");
}
}
if (oldVersion < 32) {
logger.debug("Updating database: Creating notification profile table");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
CREATE TABLE notification_profile (
_id INTEGER PRIMARY KEY,
profile_id BLOB UNIQUE NOT NULL,
name TEXT NOT NULL,
deleted_timestamp INTEGER NOT NULL DEFAULT 0,
storage_id BLOB UNIQUE,
storage_record BLOB
) STRICT;
""");
}
}
}
private static void createUuidMappingTable(

View File

@ -49,6 +49,7 @@ import org.asamk.signal.manager.storage.senderKeys.SenderKeyStore;
import org.asamk.signal.manager.storage.sessions.LegacySessionStore;
import org.asamk.signal.manager.storage.sessions.SessionStore;
import org.asamk.signal.manager.storage.stickers.LegacyStickerStore;
import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfileStore;
import org.asamk.signal.manager.storage.stickers.StickerStore;
import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore;
import org.asamk.signal.manager.util.IOUtils;
@ -181,6 +182,7 @@ public class SignalAccount implements Closeable {
private GroupStore groupStore;
private RecipientStore recipientStore;
private StickerStore stickerStore;
private NotificationProfileStore notificationProfileStore;
private UnknownStorageIdStore unknownStorageIdStore;
private ConfigurationStore configurationStore;
private KeyValueStore keyValueStore;
@ -1303,6 +1305,11 @@ public class SignalAccount implements Closeable {
return getOrCreate(() -> stickerStore, () -> stickerStore = new StickerStore(getAccountDatabase()));
}
public NotificationProfileStore getNotificationProfileStore() {
return getOrCreate(() -> notificationProfileStore,
() -> notificationProfileStore = new NotificationProfileStore(getAccountDatabase()));
}
public SenderKeyStore getSenderKeyStore() {
return getOrCreate(() -> senderKeyStore, () -> senderKeyStore = new SenderKeyStore(getAccountDatabase()));
}

View File

@ -0,0 +1,17 @@
package org.asamk.signal.manager.storage.notificationProfiles;
import org.whispersystems.signalservice.api.storage.StorageId;
/**
* Local copy of a notification profile as synced via storage service.
* The full profile data is kept in the raw storage record, the remaining fields are
* denormalized for querying.
*/
public record NotificationProfile(
long internalId,
byte[] profileId,
String name,
long deletedTimestamp,
StorageId storageId,
byte[] storageRecord
) {}

View File

@ -0,0 +1,244 @@
package org.asamk.signal.manager.storage.notificationProfiles;
import org.asamk.signal.manager.storage.Database;
import org.asamk.signal.manager.storage.Utils;
import org.asamk.signal.manager.util.KeyUtils;
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public class NotificationProfileStore {
private static final String TABLE_NOTIFICATION_PROFILE = "notification_profile";
private final Database database;
public static void createSql(Connection connection) throws SQLException {
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
CREATE TABLE notification_profile (
_id INTEGER PRIMARY KEY,
profile_id BLOB UNIQUE NOT NULL,
name TEXT NOT NULL,
deleted_timestamp INTEGER NOT NULL DEFAULT 0,
storage_id BLOB UNIQUE,
storage_record BLOB
) STRICT;
""");
}
}
public NotificationProfileStore(final Database database) {
this.database = database;
}
public List<NotificationProfile> getNotificationProfiles() {
try (final var connection = database.getConnection()) {
return getNotificationProfiles(connection);
} catch (SQLException e) {
throw new RuntimeException("Failed read from notification profile store", e);
}
}
public List<NotificationProfile> getNotificationProfiles(final Connection connection) throws SQLException {
final var sql = (
"""
SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record
FROM %s n
ORDER BY n._id
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
return Utils.executeQueryForStream(statement, this::getNotificationProfileFromResultSet).toList();
}
}
public NotificationProfile getNotificationProfile(final Connection connection, final byte[] profileId) throws SQLException {
final var sql = (
"""
SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record
FROM %s n
WHERE n.profile_id = ?
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, profileId);
return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null);
}
}
public NotificationProfile getNotificationProfile(final Connection connection, final StorageId storageId) throws SQLException {
final var sql = (
"""
SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record
FROM %s n
WHERE n.storage_id = ?
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, storageId.getRaw());
return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null);
}
}
public List<StorageId> getStorageIds(final Connection connection) throws SQLException {
final var sql = (
"""
SELECT n.storage_id
FROM %s n
WHERE n.storage_id IS NOT NULL
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList();
}
}
public void updateStorageId(
final Connection connection,
final byte[] profileId,
final StorageId storageId
) throws SQLException {
final var sql = (
"""
UPDATE %s
SET storage_id = ?
WHERE profile_id = ?
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, storageId.getRaw());
statement.setBytes(2, profileId);
statement.executeUpdate();
}
}
public void updateStorageIds(
final Connection connection,
final Map<Long, StorageId> storageIdMap
) throws SQLException {
final var sql = (
"""
UPDATE %s
SET storage_id = ?
WHERE _id = ?
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
for (final var entry : storageIdMap.entrySet()) {
statement.setBytes(1, entry.getValue().getRaw());
statement.setLong(2, entry.getKey());
statement.executeUpdate();
}
}
}
public void setMissingStorageIds() {
final var selectSql = (
"""
SELECT n._id
FROM %s n
WHERE n.storage_id IS NULL
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
final var updateSql = (
"""
UPDATE %s
SET storage_id = ?
WHERE _id = ?
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
try (final var selectStatement = connection.prepareStatement(selectSql)) {
final var ids = Utils.executeQueryForStream(selectStatement, Utils::getIdMapper).toList();
try (final var updateStatement = connection.prepareStatement(updateSql)) {
for (final var id : ids) {
updateStatement.setBytes(1, KeyUtils.createRawStorageId());
updateStatement.setLong(2, id);
updateStatement.executeUpdate();
}
}
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update notification profile store", e);
}
}
/**
* Remove profiles that were deleted remotely and are only kept locally as deletion tombstones.
*/
public int removeLocalOnlyDeletedNotificationProfiles(
final Connection connection,
final Collection<StorageId> storageIds
) throws SQLException {
final var sql = (
"""
DELETE FROM %s
WHERE storage_id = ? AND deleted_timestamp > 0
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
var count = 0;
try (final var statement = connection.prepareStatement(sql)) {
for (final var storageId : storageIds) {
statement.setBytes(1, storageId.getRaw());
count += statement.executeUpdate();
}
}
return count;
}
public void upsertFromStorageSync(
final Connection connection,
final SignalNotificationProfileRecord record
) throws SQLException {
final var remote = record.getProto();
final var profileId = remote.id.toByteArray();
final var storageRecord = remote.encode();
final var sql = (
"""
INSERT INTO %s (profile_id, name, deleted_timestamp, storage_id, storage_record)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (profile_id) DO UPDATE SET
name = excluded.name,
deleted_timestamp = excluded.deleted_timestamp,
storage_id = excluded.storage_id,
storage_record = excluded.storage_record
"""
).formatted(TABLE_NOTIFICATION_PROFILE);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, profileId);
statement.setString(2, remote.name);
statement.setLong(3, remote.deletedAtTimestampMs);
statement.setBytes(4, record.getId().getRaw());
statement.setBytes(5, storageRecord);
statement.executeUpdate();
}
}
private NotificationProfile getNotificationProfileFromResultSet(ResultSet resultSet) throws SQLException {
final var internalId = resultSet.getLong("_id");
final var profileId = resultSet.getBytes("profile_id");
final var name = resultSet.getString("name");
final var deletedTimestamp = resultSet.getLong("deleted_timestamp");
final var storageIdBytes = resultSet.getBytes("storage_id");
final var storageId = storageIdBytes == null ? null : StorageId.forNotificationProfile(storageIdBytes);
final var storageRecord = resultSet.getBytes("storage_record");
return new NotificationProfile(internalId, profileId, name, deletedTimestamp, storageId, storageRecord);
}
private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException {
final var storageId = resultSet.getBytes("storage_id");
return StorageId.forNotificationProfile(storageId);
}
}

View File

@ -0,0 +1,79 @@
package org.asamk.signal.manager.syncStorage;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
public class NotificationProfileRecordProcessor extends DefaultStorageRecordProcessor<SignalNotificationProfileRecord> {
private final SignalAccount account;
private final Connection connection;
public NotificationProfileRecordProcessor(final SignalAccount account, final Connection connection) {
this.account = account;
this.connection = connection;
}
@Override
public int compare(final SignalNotificationProfileRecord lhs, final SignalNotificationProfileRecord rhs) {
return lhs.getProto().id.equals(rhs.getProto().id) ? 0 : 1;
}
@Override
protected boolean isInvalid(final SignalNotificationProfileRecord remote) {
return remote.getProto().id.size() == 0;
}
@Override
protected Optional<SignalNotificationProfileRecord> getMatching(final SignalNotificationProfileRecord remote) throws SQLException {
final var profileId = remote.getProto().id.toByteArray();
final var local = account.getNotificationProfileStore().getNotificationProfile(connection, profileId);
if (local == null) {
return Optional.empty();
}
final StorageId storageId;
if (local.storageId() != null) {
storageId = local.storageId();
} else {
storageId = StorageId.forNotificationProfile(KeyUtils.createRawStorageId());
account.getNotificationProfileStore().updateStorageId(connection, profileId, storageId);
}
return Optional.of(new SignalNotificationProfileRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
}
@Override
protected SignalNotificationProfileRecord merge(
final SignalNotificationProfileRecord remoteRecord,
final SignalNotificationProfileRecord localRecord
) {
final var remote = remoteRecord.getProto();
final var local = localRecord.getProto();
// Profiles are only ever edited by the official clients, so remote wins.
// The one exception is an older local deletion, which must not be resurrected.
if (StickerPackRecordProcessor.shouldKeepLocalDeletion(remote.deletedAtTimestampMs,
local.deletedAtTimestampMs)) {
return localRecord;
}
return remoteRecord;
}
@Override
protected void insertLocal(final SignalNotificationProfileRecord record) throws SQLException {
account.getNotificationProfileStore().upsertFromStorageSync(connection, record);
}
@Override
protected void updateLocal(final StorageRecordUpdate<SignalNotificationProfileRecord> update) throws SQLException {
account.getNotificationProfileStore().upsertFromStorageSync(connection, update.newRecord());
}
}

View File

@ -7,6 +7,7 @@ import org.asamk.signal.manager.storage.configuration.ConfigurationStore;
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfile;
import org.asamk.signal.manager.storage.recipients.Recipient;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.signal.core.models.ServiceId.ACI;
@ -17,6 +18,7 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink;
@ -185,6 +187,15 @@ public final class StorageSyncModels {
return builder.build();
}
public static org.whispersystems.signalservice.internal.storage.protos.NotificationProfile localToRemoteRecord(
NotificationProfile notificationProfile
) {
// Notification profiles are never edited locally, the stored record is the source of truth.
final var builder = SignalNotificationProfileRecord.Companion.newBuilder(notificationProfile.storageRecord());
builder.id(ByteString.of(notificationProfile.profileId()));
return builder.build();
}
public static TrustLevel remoteToLocal(IdentityState identityState) {
return switch (identityState) {
case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED;

View File

@ -0,0 +1,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();
};
}
}

View File

@ -0,0 +1,117 @@
package org.asamk.signal.manager.storage.notificationProfiles;
import org.asamk.signal.manager.storage.AccountDatabase;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile;
import java.io.File;
import java.util.List;
import okio.ByteString;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class NotificationProfileStoreTest {
@TempDir
File tempDir;
private AccountDatabase database;
private NotificationProfileStore store;
@BeforeEach
void setUp() throws Exception {
database = AccountDatabase.init(new File(tempDir, "account.db"));
store = new NotificationProfileStore(database);
}
@AfterEach
void tearDown() {
database.close();
}
private static SignalNotificationProfileRecord record(byte[] id, String name, long deletedAt, byte[] storageId) {
return new SignalNotificationProfileRecord(StorageId.forNotificationProfile(storageId),
new NotificationProfile.Builder().id(ByteString.of(id))
.name(name)
.emoji("🌙")
.deletedAtTimestampMs(deletedAt)
.build());
}
private static byte[] bytes(int seed) {
final var result = new byte[16];
result[0] = (byte) seed;
return result;
}
@Test
void upsertInsertsAndUpdates() throws Exception {
final var id = bytes(1);
try (final var connection = database.getConnection()) {
store.upsertFromStorageSync(connection, record(id, "Sleep", 0, bytes(10)));
final var inserted = store.getNotificationProfile(connection, id);
assertNotNull(inserted);
assertEquals("Sleep", inserted.name());
assertEquals(0, inserted.deletedTimestamp());
assertArrayEquals(bytes(10), inserted.storageId().getRaw());
assertEquals("Sleep", NotificationProfile.ADAPTER.decode(inserted.storageRecord()).name);
store.upsertFromStorageSync(connection, record(id, "Sleep 2", 42, bytes(11)));
final var all = store.getNotificationProfiles(connection);
assertEquals(1, all.size());
final var updated = all.getFirst();
assertEquals(inserted.internalId(), updated.internalId());
assertEquals("Sleep 2", updated.name());
assertEquals(42, updated.deletedTimestamp());
assertArrayEquals(bytes(11), updated.storageId().getRaw());
assertNotNull(store.getNotificationProfile(connection, StorageId.forNotificationProfile(bytes(11))));
assertNull(store.getNotificationProfile(connection, StorageId.forNotificationProfile(bytes(10))));
assertEquals(List.of(StorageId.forNotificationProfile(bytes(11))), store.getStorageIds(connection));
}
}
@Test
void removesOnlyDeletedLocalOnlyProfiles() throws Exception {
try (final var connection = database.getConnection()) {
store.upsertFromStorageSync(connection, record(bytes(1), "Active", 0, bytes(10)));
store.upsertFromStorageSync(connection, record(bytes(2), "Deleted", 42, bytes(11)));
final var removed = store.removeLocalOnlyDeletedNotificationProfiles(connection,
List.of(StorageId.forNotificationProfile(bytes(10)), StorageId.forNotificationProfile(bytes(11))));
assertEquals(1, removed);
assertNotNull(store.getNotificationProfile(connection, bytes(1)));
assertNull(store.getNotificationProfile(connection, bytes(2)));
}
}
@Test
void updatesStorageIds() throws Exception {
try (final var connection = database.getConnection()) {
store.upsertFromStorageSync(connection, record(bytes(1), "A", 0, bytes(10)));
store.upsertFromStorageSync(connection, record(bytes(2), "B", 0, bytes(11)));
final var profiles = store.getNotificationProfiles(connection);
store.updateStorageIds(connection,
java.util.Map.of(profiles.get(0).internalId(),
StorageId.forNotificationProfile(bytes(20)),
profiles.get(1).internalId(),
StorageId.forNotificationProfile(bytes(21))));
assertArrayEquals(bytes(20), store.getNotificationProfile(connection, bytes(1)).storageId().getRaw());
assertArrayEquals(bytes(21), store.getNotificationProfile(connection, bytes(2)).storageId().getRaw());
}
}
}

View File

@ -3,9 +3,15 @@ package org.asamk.signal.manager.syncStorage;
import org.junit.jupiter.api.Test;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile;
import java.util.UUID;
import okio.ByteString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -30,6 +36,29 @@ class StorageRecordProcessorTest {
assertFalse(ContactRecordProcessor.shouldUseRemoteIdentityKey(false, false, 33, 33, 0, false));
}
@Test
void notificationProfileMergePrefersRemoteExceptOlderLocalDeletion() {
final var id = ByteString.of(new byte[]{1, 2, 3});
final var remote = new SignalNotificationProfileRecord(StorageId.forNotificationProfile(new byte[16]),
new NotificationProfile.Builder().id(id).name("Remote").deletedAtTimestampMs(0).build());
final var local = new SignalNotificationProfileRecord(StorageId.forNotificationProfile(new byte[16]),
new NotificationProfile.Builder().id(id).name("Local").deletedAtTimestampMs(0).build());
final var processor = new NotificationProfileRecordProcessor(null, null);
assertEquals(remote, processor.merge(remote, local));
final var remoteDeleted = new SignalNotificationProfileRecord(remote.getId(),
remote.getProto().newBuilder().deletedAtTimestampMs(200).build());
final var localDeleted = new SignalNotificationProfileRecord(local.getId(),
local.getProto().newBuilder().deletedAtTimestampMs(100).build());
assertEquals(localDeleted, processor.merge(remoteDeleted, localDeleted));
assertEquals(remoteDeleted, processor.merge(remoteDeleted, local));
assertTrue(processor.isInvalid(new SignalNotificationProfileRecord(remote.getId(),
new NotificationProfile.Builder().name("No id").build())));
assertFalse(processor.isInvalid(remote));
}
@Test
void keepsOlderLocalStickerDeletion() {
assertTrue(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 100));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}
}

View File

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

View 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)));
}
}