From 5a2e297991276b2a478eae5a1cc0dc92e5605b43 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Thu, 16 Jan 2025 16:52:46 -0600 Subject: [PATCH] Add ZeroTtlDevicePushNotificationExperiment --- .../textsecuregcm/WhisperServerService.java | 24 ++ .../IdleDevicePushNotificationExperiment.java | 32 +-- .../ZeroTtlPushNotificationExperiment.java | 51 ++++ .../textsecuregcm/push/FcmSender.java | 8 +- .../textsecuregcm/push/PushNotification.java | 4 +- .../push/PushNotificationManager.java | 16 +- .../push/PushNotificationScheduler.java | 5 +- .../push/ZeroTtlNotificationScheduler.java | 99 +++++++ .../workers/IdleWakeupEligibilityChecker.java | 96 +++++++ .../workers/NotifyIdleDevicesCommand.java | 70 +---- ...xperimentNotificationSchedulerFactory.java | 26 ++ ...oTtlPushNotificationExperimentFactory.java | 36 +++ .../textsecuregcm/push/APNSenderTest.java | 12 +- .../textsecuregcm/push/FcmSenderTest.java | 8 +- .../push/PushNotificationManagerTest.java | 20 +- .../IdleWakeupEligibilityCheckerTest.java | 272 ++++++++++++++++++ .../workers/NotifyIdleDevicesCommandTest.java | 228 +-------------- 17 files changed, 668 insertions(+), 339 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/experiment/ZeroTtlPushNotificationExperiment.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/ZeroTtlNotificationScheduler.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityChecker.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlExperimentNotificationSchedulerFactory.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlPushNotificationExperimentFactory.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityCheckerTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 47763080f..dc01436ed 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -265,6 +265,8 @@ import org.whispersystems.textsecuregcm.workers.BackupMetricsCommand; import org.whispersystems.textsecuregcm.workers.CertificateCommand; import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand; import org.whispersystems.textsecuregcm.workers.DeleteUserCommand; +import org.whispersystems.textsecuregcm.workers.DiscardPushNotificationExperimentSamplesCommand; +import org.whispersystems.textsecuregcm.workers.FinishPushNotificationExperimentCommand; import org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerFactory; import org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand; import org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesCommand; @@ -277,7 +279,10 @@ import org.whispersystems.textsecuregcm.workers.ScheduledApnPushNotificationSend import org.whispersystems.textsecuregcm.workers.ServerVersionCommand; import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask; import org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand; +import org.whispersystems.textsecuregcm.workers.StartPushNotificationExperimentCommand; import org.whispersystems.textsecuregcm.workers.UnlinkDeviceCommand; +import org.whispersystems.textsecuregcm.workers.ZeroTtlExperimentNotificationSchedulerFactory; +import org.whispersystems.textsecuregcm.workers.ZeroTtlPushNotificationExperimentFactory; import org.whispersystems.textsecuregcm.workers.ZkParamsCommand; import org.whispersystems.websocket.WebSocketResourceProviderFactory; import org.whispersystems.websocket.setup.WebSocketEnvironment; @@ -331,6 +336,25 @@ public class WhisperServerService extends Application("start-zero-ttl-push-notification-experiment", + "Start an experiment to send push notifications with ttl=0 to idle android devices", + new ZeroTtlPushNotificationExperimentFactory())); + + bootstrap.addCommand( + new FinishPushNotificationExperimentCommand<>("finish-zero-ttl-push-notification-experiment", + "Finish an experiment to send push notifications with ttl=0 to idle android devices", + new ZeroTtlPushNotificationExperimentFactory())); + + bootstrap.addCommand( + new DiscardPushNotificationExperimentSamplesCommand("discard-zero-ttl-push-notification-experiment", + "Discard samples from the \"zero TTL push notification\" experiment", + new ZeroTtlPushNotificationExperimentFactory())); + + bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-zero-ttl-notification-jobs", + "Processes scheduled jobs to send zero-ttl experiment notifications to idle devices", + new ZeroTtlExperimentNotificationSchedulerFactory())); + bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-idle-device-notification-jobs", "Processes scheduled jobs to send notifications to idle devices", new IdleDeviceNotificationSchedulerFactory())); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java index b4b9ff002..fc3779d29 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java @@ -6,18 +6,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.workers.IdleWakeupEligibilityChecker; import reactor.core.publisher.Flux; import javax.annotation.Nullable; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; import java.util.Collections; import java.util.EnumMap; import java.util.Map; +import java.util.concurrent.CompletableFuture; abstract class IdleDevicePushNotificationExperiment implements PushNotificationExperiment { - private final Clock clock; + private final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker; private final Logger log = LoggerFactory.getLogger(getClass()); @@ -37,19 +36,8 @@ abstract class IdleDevicePushNotificationExperiment implements PushNotificationE UNCHANGED } - protected IdleDevicePushNotificationExperiment(final Clock clock) { - this.clock = clock; - } - - protected abstract Duration getMinIdleDuration(); - - protected abstract Duration getMaxIdleDuration(); - - @VisibleForTesting - boolean isIdle(final Device device) { - final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant()); - - return idleDuration.compareTo(getMinIdleDuration()) >= 0 && idleDuration.compareTo(getMaxIdleDuration()) < 0; + protected IdleDevicePushNotificationExperiment(final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker) { + this.idleWakeupEligibilityChecker = idleWakeupEligibilityChecker; } @VisibleForTesting @@ -57,11 +45,18 @@ abstract class IdleDevicePushNotificationExperiment implements PushNotificationE return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId()); } + abstract boolean isIdleDeviceEligible(final Account account, final Device idleDevice, final DeviceLastSeenState state); + + @Override + public CompletableFuture isDeviceEligible(final Account account, final Device device) { + return idleWakeupEligibilityChecker.isDeviceEligible(account, device).thenApply(idle -> + idle && isIdleDeviceEligible(account, device, getState(account, device))); + } + @Override public DeviceLastSeenState getState(@Nullable final Account account, @Nullable final Device device) { if (account != null && device != null) { final DeviceLastSeenState.PushTokenType pushTokenType; - if (StringUtils.isNotBlank(device.getApnId())) { pushTokenType = DeviceLastSeenState.PushTokenType.APNS; } else if (StringUtils.isNotBlank(device.getGcmId())) { @@ -69,7 +64,6 @@ abstract class IdleDevicePushNotificationExperiment implements PushNotificationE } else { pushTokenType = null; } - return new DeviceLastSeenState(true, device.getCreated(), hasPushToken(device), device.getLastSeen(), pushTokenType); } else { return DeviceLastSeenState.MISSING_DEVICE_STATE; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ZeroTtlPushNotificationExperiment.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ZeroTtlPushNotificationExperiment.java new file mode 100644 index 000000000..2ca4168ea --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ZeroTtlPushNotificationExperiment.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.experiment; + +import org.whispersystems.textsecuregcm.push.ZeroTtlNotificationScheduler; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.workers.IdleWakeupEligibilityChecker; +import java.time.LocalTime; +import java.util.concurrent.CompletableFuture; + +public class ZeroTtlPushNotificationExperiment extends IdleDevicePushNotificationExperiment { + private static final LocalTime PREFERRED_NOTIFICATION_TIME = LocalTime.of(14, 0); + + private final ZeroTtlNotificationScheduler zeroTtlNotificationScheduler; + + public ZeroTtlPushNotificationExperiment( + final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker, + final ZeroTtlNotificationScheduler zeroTtlNotificationScheduler) { + super(idleWakeupEligibilityChecker); + this.zeroTtlNotificationScheduler = zeroTtlNotificationScheduler; + } + + @Override + boolean isIdleDeviceEligible(final Account account, final Device idleDevice, final DeviceLastSeenState state) { + return state.pushTokenType() == DeviceLastSeenState.PushTokenType.FCM; + } + + @Override + public String getExperimentName() { + return "zero-ttl-notification"; + } + + @Override + public Class getStateClass() { + return DeviceLastSeenState.class; + } + + @Override + public CompletableFuture applyExperimentTreatment(final Account account, final Device device) { + return zeroTtlNotificationScheduler.scheduleNotification(account, device, PREFERRED_NOTIFICATION_TIME, true); + } + + @Override + public CompletableFuture applyControlTreatment(final Account account, final Device device) { + return zeroTtlNotificationScheduler.scheduleNotification(account, device, PREFERRED_NOTIFICATION_TIME, false); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java index 8074803ba..ba7936f6c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java @@ -78,11 +78,13 @@ public class FcmSender implements PushNotificationSender { @Override public CompletableFuture sendNotification(PushNotification pushNotification) { + final AndroidConfig.Builder androidConfig = AndroidConfig.builder() + .setPriority(pushNotification.urgent() ? AndroidConfig.Priority.HIGH : AndroidConfig.Priority.NORMAL); + pushNotification.ttl().ifPresent(androidConfig::setTtl); + Message.Builder builder = Message.builder() .setToken(pushNotification.deviceToken()) - .setAndroidConfig(AndroidConfig.builder() - .setPriority(pushNotification.urgent() ? AndroidConfig.Priority.HIGH : AndroidConfig.Priority.NORMAL) - .build()); + .setAndroidConfig(androidConfig.build()); final String key = switch (pushNotification.notificationType()) { case NOTIFICATION -> "newMessageAlert"; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java index 3f3738de5..37af813b1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.push; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Device; import javax.annotation.Nullable; +import java.util.Optional; public record PushNotification(String deviceToken, TokenType tokenType, @@ -15,7 +16,8 @@ public record PushNotification(String deviceToken, @Nullable String data, @Nullable Account destination, @Nullable Device destinationDevice, - boolean urgent) { + boolean urgent, + Optional ttl) { public enum NotificationType { NOTIFICATION, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java index 2780d78a0..45d3bdd20 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java @@ -51,11 +51,19 @@ public class PushNotificationManager { final Pair tokenAndType = getToken(device); return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), - PushNotification.NotificationType.NOTIFICATION, null, destination, device, urgent)); + PushNotification.NotificationType.NOTIFICATION, null, destination, device, urgent, Optional.empty())); + } + + public CompletableFuture> sendNewMessageNotificationWithTtl(final Account destination, final byte destinationDeviceId, final boolean urgent, long ttl) throws NotPushRegisteredException { + final Device device = destination.getDevice(destinationDeviceId).orElseThrow(NotPushRegisteredException::new); + final Pair tokenAndType = getToken(device); + + return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), + PushNotification.NotificationType.NOTIFICATION, null, destination, device, urgent, Optional.of(ttl))); } public CompletableFuture sendRegistrationChallengeNotification(final String deviceToken, final PushNotification.TokenType tokenType, final String challengeToken) { - return sendNotification(new PushNotification(deviceToken, tokenType, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true)) + return sendNotification(new PushNotification(deviceToken, tokenType, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true, Optional.empty())) .thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError("Responses must be present for urgent notifications"))); } @@ -66,7 +74,7 @@ public class PushNotificationManager { final Pair tokenAndType = getToken(device); return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), - PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, destination, device, true)) + PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, destination, device, true, Optional.empty())) .thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError("Responses must be present for urgent notifications"))); } @@ -76,7 +84,7 @@ public class PushNotificationManager { return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, - context, destination, device, true)) + context, destination, device, true, Optional.empty())) .thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError("Responses must be present for urgent notifications"))); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationScheduler.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationScheduler.java index 5596cba81..329c11514 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationScheduler.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationScheduler.java @@ -286,7 +286,7 @@ public class PushNotificationScheduler implements Managed { return pushSchedulingCluster.withCluster(connection -> connection.async().set( getLastBackgroundApnsNotificationTimestampKey(account, device), String.valueOf(clock.millis()), new SetArgs().ex(BACKGROUND_NOTIFICATION_PERIOD))) - .thenCompose(ignored -> apnSender.sendNotification(new PushNotification(device.getApnId(), PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, false))) + .thenCompose(ignored -> apnSender.sendNotification(new PushNotification(device.getApnId(), PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, false, Optional.empty()))) .thenAccept(response -> Metrics.counter(BACKGROUND_NOTIFICATION_SENT_COUNTER_NAME, ACCEPTED_TAG, String.valueOf(response.accepted())) .increment()) @@ -308,7 +308,8 @@ public class PushNotificationScheduler implements Managed { null, account, device, - true); + true, + Optional.empty()); final PushNotificationSender pushNotificationSender = isApnsDevice ? apnSender : fcmSender; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ZeroTtlNotificationScheduler.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ZeroTtlNotificationScheduler.java new file mode 100644 index 000000000..942feffad --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/ZeroTtlNotificationScheduler.java @@ -0,0 +1,99 @@ +package org.whispersystems.textsecuregcm.push; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.annotations.VisibleForTesting; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalTime; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; +import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.scheduler.JobScheduler; +import org.whispersystems.textsecuregcm.scheduler.SchedulingUtil; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; + +public class ZeroTtlNotificationScheduler extends JobScheduler { + + private final AccountsManager accountsManager; + private final PushNotificationManager pushNotificationManager; + private final Clock clock; + + @VisibleForTesting + record JobDescriptor(UUID accountIdentifier, byte deviceId, long lastSeen, boolean zeroTtl) {} + + public ZeroTtlNotificationScheduler( + final AccountsManager accountsManager, + final PushNotificationManager pushNotificationManager, + final DynamoDbAsyncClient dynamoDbAsyncClient, + final String tableName, + final Duration jobExpiration, + final Clock clock) { + + super(dynamoDbAsyncClient, tableName, jobExpiration, clock); + + this.accountsManager = accountsManager; + this.pushNotificationManager = pushNotificationManager; + this.clock = clock; + } + + @Override + public String getSchedulerName() { + return "ZeroTtlNotification"; + } + + @Override + protected CompletableFuture processJob(@Nullable final byte[] jobData) { + final JobDescriptor jobDescriptor; + + try { + jobDescriptor = SystemMapper.jsonMapper().readValue(jobData, JobDescriptor.class); + } catch (final IOException e) { + return CompletableFuture.failedFuture(e); + } + + return accountsManager.getByAccountIdentifierAsync(jobDescriptor.accountIdentifier()) + .thenCompose(maybeAccount -> maybeAccount.map(account -> + account.getDevice(jobDescriptor.deviceId()).map(device -> { + if (jobDescriptor.lastSeen() != device.getLastSeen()) { + return CompletableFuture.completedFuture("deviceSeenRecently"); + } + + try { + return sendNotification(account, jobDescriptor) + .thenApply(ignored -> "sent"); + } catch (final NotPushRegisteredException e) { + return CompletableFuture.completedFuture("deviceTokenDeleted"); + } + }) + .orElse(CompletableFuture.completedFuture("deviceDeleted"))) + .orElse(CompletableFuture.completedFuture("accountDeleted"))); + } + + private CompletableFuture> sendNotification(final Account account, + final JobDescriptor jobDescriptor) throws NotPushRegisteredException { + return jobDescriptor.zeroTtl() + ? pushNotificationManager.sendNewMessageNotificationWithTtl(account, jobDescriptor.deviceId(), true, 0L) + : pushNotificationManager.sendNewMessageNotification(account, jobDescriptor.deviceId(), true); + } + + public CompletableFuture scheduleNotification(final Account account, final Device device, + final LocalTime preferredDeliveryTime, boolean zeroTtl) { + final Instant runAt = SchedulingUtil.getNextRecommendedNotificationTime(account, preferredDeliveryTime, clock); + + try { + return scheduleJob(runAt, SystemMapper.jsonMapper().writeValueAsBytes( + new JobDescriptor(account.getIdentifier(IdentityType.ACI), device.getId(), device.getLastSeen(), zeroTtl))); + } catch (final JsonProcessingException e) { + // This should never happen when serializing an `AccountAndDeviceIdentifier` + throw new AssertionError(e); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityChecker.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityChecker.java new file mode 100644 index 000000000..c166e4816 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityChecker.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.workers; + +import com.google.common.annotations.VisibleForTesting; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.StringUtils; +import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.MessagesManager; +import reactor.core.publisher.Mono; + +/** + * Checks if a device may benefit from receiving a push notification + */ +public class IdleWakeupEligibilityChecker { + + @VisibleForTesting + static final Duration MIN_SHORT_IDLE_DURATION = Duration.ofDays(3); + + @VisibleForTesting + static final Duration MAX_SHORT_IDLE_DURATION = Duration.ofDays(30); + + @VisibleForTesting + static final Duration MIN_LONG_IDLE_DURATION = Duration.ofDays(60); + + @VisibleForTesting + static final Duration MAX_LONG_IDLE_DURATION = Duration.ofDays(75); + + private final MessagesManager messagesManager; + private final Clock clock; + + public IdleWakeupEligibilityChecker(final Clock clock, final MessagesManager messagesManager) { + this.messagesManager = messagesManager; + this.clock = clock; + } + + /** + * Determine whether the device may benefit from a push notification. + * + * @param account The account to check + * @param device The device to check + * @return true if the device may benefit from a push notification, otherwise false + * @implNote There are two populations that may benefit from a wakeup: + *
    + *
  1. Devices that have only been idle for a little while, but have messages that they don't seem to be retrieving + *
  2. Devices that have been idle for a long time, but don't have any messages + *
+ * We think the first group sometimes just needs a little nudge to wake up and get their messages, and the latter + * group generally WOULD get their messages if they had any. We want to notify the first group to prompt them to + * actually get their messages and the latter group to prevent them from getting deleted due to inactivity (since they + * are otherwise healthy installations that just aren't getting much traffic). + */ + public CompletableFuture isDeviceEligible(final Account account, final Device device) { + + if (!hasPushToken(device)) { + return CompletableFuture.completedFuture(false); + } + + if (isShortIdle(device, clock)) { + return messagesManager.mayHaveUrgentPersistedMessages(account.getIdentifier(IdentityType.ACI), device); + } else if (isLongIdle(device, clock)) { + return messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device) + .thenApply(mayHavePersistedMessages -> !mayHavePersistedMessages); + } else { + return CompletableFuture.completedFuture(false); + } + } + + @VisibleForTesting + static boolean isShortIdle(final Device device, final Clock clock) { + final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant()); + + return idleDuration.compareTo(MIN_SHORT_IDLE_DURATION) >= 0 && idleDuration.compareTo(MAX_SHORT_IDLE_DURATION) < 0; + } + + @VisibleForTesting + static boolean isLongIdle(final Device device, final Clock clock) { + final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant()); + + return idleDuration.compareTo(MIN_LONG_IDLE_DURATION) >= 0 && idleDuration.compareTo(MAX_LONG_IDLE_DURATION) < 0; + } + + @VisibleForTesting + static boolean hasPushToken(final Device device) { + return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId()); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommand.java index 9ce4a27f2..394b796d5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommand.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommand.java @@ -4,7 +4,6 @@ import com.google.common.annotations.VisibleForTesting; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Metrics; import net.sourceforge.argparse4j.inf.Subparser; -import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; @@ -18,8 +17,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.function.Tuples; import java.time.Clock; -import java.time.Duration; -import java.time.Instant; import java.time.LocalTime; public class NotifyIdleDevicesCommand extends AbstractSinglePassCrawlAccountsCommand { @@ -35,18 +32,6 @@ public class NotifyIdleDevicesCommand extends AbstractSinglePassCrawlAccountsCom @VisibleForTesting static final LocalTime PREFERRED_NOTIFICATION_TIME = LocalTime.of(14, 0); - @VisibleForTesting - static final Duration MIN_SHORT_IDLE_DURATION = Duration.ofDays(3); - - @VisibleForTesting - static final Duration MAX_SHORT_IDLE_DURATION = Duration.ofDays(30); - - @VisibleForTesting - static final Duration MIN_LONG_IDLE_DURATION = Duration.ofDays(60); - - @VisibleForTesting - static final Duration MAX_LONG_IDLE_DURATION = Duration.ofDays(75); - private static final Counter DEVICE_INSPECTED_COUNTER = Metrics.counter(MetricsUtil.name(NotifyIdleDevicesCommand.class, "deviceInspected")); @@ -87,12 +72,15 @@ public class NotifyIdleDevicesCommand extends AbstractSinglePassCrawlAccountsCom final MessagesManager messagesManager = getCommandDependencies().messagesManager(); final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler = buildIdleDeviceNotificationScheduler(); final Clock clock = getClock(); + final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker = new IdleWakeupEligibilityChecker(clock, messagesManager); accounts .flatMap(account -> Flux.fromIterable(account.getDevices()).map(device -> Tuples.of(account, device))) .doOnNext(ignored -> DEVICE_INSPECTED_COUNTER.increment()) - .flatMap(accountAndDevice -> isDeviceEligible(accountAndDevice.getT1(), accountAndDevice.getT2(), messagesManager, clock) - .mapNotNull(eligible -> eligible ? accountAndDevice : null), maxConcurrency) + .flatMap(accountAndDevice -> Mono.fromFuture(() -> + idleWakeupEligibilityChecker.isDeviceEligible(accountAndDevice.getT1(), accountAndDevice.getT2())) + .mapNotNull(eligible -> eligible ? accountAndDevice : null), + maxConcurrency) .flatMap(accountAndDevice -> { final Account account = accountAndDevice.getT1(); final Device device = accountAndDevice.getT2(); @@ -136,52 +124,4 @@ public class NotifyIdleDevicesCommand extends AbstractSinglePassCrawlAccountsCom Clock.systemUTC()); } - @VisibleForTesting - static Mono isDeviceEligible(final Account account, - final Device device, - final MessagesManager messagesManager, - final Clock clock) { - - // There are two populations of interest for this crawler: - // - // 1. Devices that have only been idle for a little while, but have messages that they don't seem to be retrieving - // 2. Devices that have been idle for a long time, but don't have any messages - // - // We think the first group sometimes just needs a little nudge to wake up and get their messages, and the latter - // group generally WOULD get their messages if they had any. We want to notify the first group to prompt them to - // actually get their messages and the latter group to prevent them from getting deleted due to inactivity (since - // they are otherwise healthy installations that just aren't getting much traffic). - - if (!hasPushToken(device)) { - return Mono.just(false); - } - - if (isShortIdle(device, clock)) { - return Mono.fromFuture(messagesManager.mayHaveUrgentPersistedMessages(account.getIdentifier(IdentityType.ACI), device)); - } else if (isLongIdle(device, clock)) { - return Mono.fromFuture(messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device)) - .map(mayHavePersistedMessages -> !mayHavePersistedMessages); - } else { - return Mono.just(false); - } - } - - @VisibleForTesting - static boolean isShortIdle(final Device device, final Clock clock) { - final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant()); - - return idleDuration.compareTo(MIN_SHORT_IDLE_DURATION) >= 0 && idleDuration.compareTo(MAX_SHORT_IDLE_DURATION) < 0; - } - - @VisibleForTesting - static boolean isLongIdle(final Device device, final Clock clock) { - final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant()); - - return idleDuration.compareTo(MIN_LONG_IDLE_DURATION) >= 0 && idleDuration.compareTo(MAX_LONG_IDLE_DURATION) < 0; - } - - @VisibleForTesting - static boolean hasPushToken(final Device device) { - return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId()); - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlExperimentNotificationSchedulerFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlExperimentNotificationSchedulerFactory.java new file mode 100644 index 000000000..6439374b6 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlExperimentNotificationSchedulerFactory.java @@ -0,0 +1,26 @@ +package org.whispersystems.textsecuregcm.workers; + +import java.time.Clock; +import org.whispersystems.textsecuregcm.WhisperServerConfiguration; +import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; +import org.whispersystems.textsecuregcm.push.ZeroTtlNotificationScheduler; +import org.whispersystems.textsecuregcm.scheduler.JobScheduler; + +public class ZeroTtlExperimentNotificationSchedulerFactory implements JobSchedulerFactory { + + @Override + public JobScheduler buildJobScheduler(final CommandDependencies commandDependencies, + final WhisperServerConfiguration configuration) { + final DynamoDbTables.TableWithExpiration tableConfiguration = configuration.getDynamoDbTables().getScheduledJobs(); + + final Clock clock = Clock.systemUTC(); + + return new ZeroTtlNotificationScheduler( + commandDependencies.accountsManager(), + commandDependencies.pushNotificationManager(), + commandDependencies.dynamoDbAsyncClient(), + tableConfiguration.getTableName(), + tableConfiguration.getExpiration(), + clock); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlPushNotificationExperimentFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlPushNotificationExperimentFactory.java new file mode 100644 index 000000000..1134203d0 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZeroTtlPushNotificationExperimentFactory.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.workers; + +import org.whispersystems.textsecuregcm.WhisperServerConfiguration; +import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; +import org.whispersystems.textsecuregcm.experiment.DeviceLastSeenState; +import org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment; +import org.whispersystems.textsecuregcm.experiment.ZeroTtlPushNotificationExperiment; +import org.whispersystems.textsecuregcm.push.ZeroTtlNotificationScheduler; +import java.time.Clock; + +public class ZeroTtlPushNotificationExperimentFactory implements PushNotificationExperimentFactory { + + @Override + public PushNotificationExperiment buildExperiment(final CommandDependencies commandDependencies, + final WhisperServerConfiguration configuration) { + + final DynamoDbTables.TableWithExpiration tableConfiguration = configuration.getDynamoDbTables().getScheduledJobs(); + + final Clock clock = Clock.systemUTC(); + + return new ZeroTtlPushNotificationExperiment( + new IdleWakeupEligibilityChecker(clock, commandDependencies.messagesManager()), + new ZeroTtlNotificationScheduler( + commandDependencies.accountsManager(), + commandDependencies.pushNotificationManager(), + commandDependencies.dynamoDbAsyncClient(), + tableConfiguration.getTableName(), + tableConfiguration.getExpiration(), + clock)); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java index 250ff3285..05c634875 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java @@ -69,7 +69,8 @@ class APNSenderTest { (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, urgent); + PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, urgent, + Optional.empty()); final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); @@ -113,7 +114,8 @@ class APNSenderTest { (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true); + PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true, + Optional.empty()); when(destinationDevice.getApnId()).thenReturn(DESTINATION_DEVICE_TOKEN); when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11)); @@ -144,7 +146,8 @@ class APNSenderTest { (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true); + PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true, + Optional.empty()); final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); @@ -171,7 +174,8 @@ class APNSenderTest { new IOException("lost connection"))); PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true); + PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true, + Optional.empty()); assertThatThrownBy(() -> apnSender.sendNotification(pushNotification).join()) .isInstanceOf(CompletionException.class) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java index c6b5088c9..8244b7488 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java @@ -54,7 +54,7 @@ class FcmSenderTest { @Test void testSendMessage() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true, Optional.empty()); final SettableApiFuture sendFuture = SettableApiFuture.create(); sendFuture.set("message-id"); @@ -71,7 +71,7 @@ class FcmSenderTest { @Test void testSendMessageRejected() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true, Optional.empty()); final FirebaseMessagingException invalidArgumentException = mock(FirebaseMessagingException.class); when(invalidArgumentException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.INVALID_ARGUMENT); @@ -91,7 +91,7 @@ class FcmSenderTest { @Test void testSendMessageUnregistered() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true, Optional.empty()); final FirebaseMessagingException unregisteredException = mock(FirebaseMessagingException.class); when(unregisteredException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED); @@ -111,7 +111,7 @@ class FcmSenderTest { @Test void testSendMessageException() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true, Optional.empty()); final SettableApiFuture sendFuture = SettableApiFuture.create(); sendFuture.setException(new IOException()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java index c1153d6e4..b955dd498 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java @@ -66,7 +66,7 @@ class PushNotificationManagerTest { .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); pushNotificationManager.sendNewMessageNotification(account, Device.PRIMARY_ID, urgent); - verify(fcmSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent)); + verify(fcmSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent, Optional.empty())); } @Test @@ -78,7 +78,7 @@ class PushNotificationManagerTest { .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); pushNotificationManager.sendRegistrationChallengeNotification(deviceToken, PushNotification.TokenType.APN, challengeToken); - verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true)); + verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true, Optional.empty())); } @Test @@ -97,7 +97,7 @@ class PushNotificationManagerTest { .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); pushNotificationManager.sendRateLimitChallengeNotification(account, challengeToken); - verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, account, device, true)); + verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, account, device, true, Optional.empty())); } @ParameterizedTest @@ -124,10 +124,10 @@ class PushNotificationManagerTest { if (isApn){ verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, - PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, "someContext", account, device, true)); + PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, "someContext", account, device, true, Optional.empty())); } else { verify(fcmSender, times(1)).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM, - PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, "someContext", account, device, true)); + PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, "someContext", account, device, true, Optional.empty())); } } @@ -141,7 +141,7 @@ class PushNotificationManagerTest { when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device)); final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent); + "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent, Optional.empty()); when(fcmSender.sendNotification(pushNotification)) .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); @@ -165,7 +165,7 @@ class PushNotificationManagerTest { when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device)); final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent); + "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent, Optional.empty()); when(apnSender.sendNotification(pushNotification)) .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); @@ -200,7 +200,7 @@ class PushNotificationManagerTest { when(accountsManager.getByAccountIdentifier(aci)).thenReturn(Optional.of(account)); final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, true); + "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, true, Optional.empty()); when(fcmSender.sendNotification(pushNotification)) .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.empty()))); @@ -225,7 +225,7 @@ class PushNotificationManagerTest { when(accountsManager.getByAccountIdentifier(aci)).thenReturn(Optional.of(account)); final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true); + "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true, Optional.empty()); when(apnSender.sendNotification(pushNotification)) .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.empty()))); @@ -256,7 +256,7 @@ class PushNotificationManagerTest { when(accountsManager.getByAccountIdentifier(aci)).thenReturn(Optional.of(account)); final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true); + "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true, Optional.empty()); when(apnSender.sendNotification(pushNotification)) .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.of(tokenTimestamp.minusSeconds(60))))); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityCheckerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityCheckerTest.java new file mode 100644 index 000000000..c5703f146 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityCheckerTest.java @@ -0,0 +1,272 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.workers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.MessagesManager; + +public class IdleWakeupEligibilityCheckerTest { + + private static final Instant CURRENT_TIME = Instant.now(); + private final Clock clock = Clock.fixed(CURRENT_TIME, ZoneId.systemDefault()); + + private MessagesManager messagesManager; + private IdleWakeupEligibilityChecker idleChecker; + + @BeforeEach + void setup() { + messagesManager = mock(MessagesManager.class); + idleChecker = new IdleWakeupEligibilityChecker(clock, messagesManager); + } + + @ParameterizedTest + @MethodSource + void isDeviceEligible(final Account account, + final Device device, + final boolean mayHaveMessages, + final boolean mayHaveUrgentMessages, + final boolean expectEligible) { + + when(messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device)) + .thenReturn(CompletableFuture.completedFuture(mayHaveMessages)); + + when(messagesManager.mayHaveUrgentPersistedMessages(account.getIdentifier(IdentityType.ACI), device)) + .thenReturn(CompletableFuture.completedFuture(mayHaveUrgentMessages)); + + assertEquals(expectEligible, idleChecker.isDeviceEligible(account, device).join()); + } + + private static List isDeviceEligible() { + final List arguments = new ArrayList<>(); + + final Account account = mock(Account.class); + when(account.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID()); + when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format( + PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164)); + + { + // Long-idle device with push token and messages + final Device device = mock(Device.class); + when(device.getApnId()).thenReturn("apns-token"); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, true, false)); + } + + { + // Long-idle device missing push token, but with messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, true, false)); + } + + { + // Long-idle device missing push token and messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, false, false, false)); + } + + { + // Long-idle device with push token, but no messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli()); + when(device.getApnId()).thenReturn("apns-token"); + + arguments.add(Arguments.of(account, device, false, false, true)); + } + + { + // Short-idle device with push token and urgent messages + final Device device = mock(Device.class); + when(device.getApnId()).thenReturn("apns-token"); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, true, true)); + } + + { + // Short-idle device with push token and only non-urgent messages + final Device device = mock(Device.class); + when(device.getApnId()).thenReturn("apns-token"); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, false, false)); + } + + { + // Short-idle device missing push token, but with urgent messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, true, false)); + } + + { + // Short-idle device missing push token and messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, false, false, false)); + } + + { + // Short-idle device with push token, but no messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn( + CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli()); + when(device.getApnId()).thenReturn("apns-token"); + + arguments.add(Arguments.of(account, device, false, false, false)); + } + + { + // Active device with push token and urgent messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + when(device.getApnId()).thenReturn("apns-token"); + + arguments.add(Arguments.of(account, device, true, true, false)); + } + + { + // Active device missing push token, but with urgent messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, true, false)); + } + + { + // Active device missing push token and messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + + arguments.add(Arguments.of(account, device, false, false, false)); + } + + { + // Active device with push token, but no messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + when(device.getApnId()).thenReturn("apns-token"); + + arguments.add(Arguments.of(account, device, false, false, false)); + } + + return arguments; + } + + @ParameterizedTest + @MethodSource + void isShortIdle(final Duration idleDuration, final boolean expectIdle) { + final Instant currentTime = Instant.now(); + final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault()); + + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(currentTime.minus(idleDuration).toEpochMilli()); + + assertEquals(expectIdle, IdleWakeupEligibilityChecker.isShortIdle(device, clock)); + } + + private static List isShortIdle() { + return List.of( + Arguments.of(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION, true), + Arguments.of(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION.plusMillis(1), true), + Arguments.of(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION.minusMillis(1), false), + Arguments.of(IdleWakeupEligibilityChecker.MAX_SHORT_IDLE_DURATION, false), + Arguments.of(IdleWakeupEligibilityChecker.MAX_SHORT_IDLE_DURATION.plusMillis(1), false), + Arguments.of(IdleWakeupEligibilityChecker.MAX_SHORT_IDLE_DURATION.minusMillis(1), true) + ); + } + + @ParameterizedTest + @MethodSource + void isLongIdle(final Duration idleDuration, final boolean expectIdle) { + final Instant currentTime = Instant.now(); + final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault()); + + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(currentTime.minus(idleDuration).toEpochMilli()); + + assertEquals(expectIdle, IdleWakeupEligibilityChecker.isLongIdle(device, clock)); + } + + private static List isLongIdle() { + return List.of( + Arguments.of(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION, true), + Arguments.of(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION.plusMillis(1), true), + Arguments.of(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION.minusMillis(1), false), + Arguments.of(IdleWakeupEligibilityChecker.MAX_LONG_IDLE_DURATION, false), + Arguments.of(IdleWakeupEligibilityChecker.MAX_LONG_IDLE_DURATION.plusMillis(1), false), + Arguments.of(IdleWakeupEligibilityChecker.MAX_LONG_IDLE_DURATION.minusMillis(1), true) + ); + } + + @ParameterizedTest + @MethodSource + void hasPushToken(final Device device, final boolean expectHasPushToken) { + assertEquals(expectHasPushToken, IdleWakeupEligibilityChecker.hasPushToken(device)); + } + + private static List hasPushToken() { + final List arguments = new ArrayList<>(); + + { + // No token at all + final Device device = mock(Device.class); + + arguments.add(Arguments.of(device, false)); + } + + { + // FCM token + final Device device = mock(Device.class); + when(device.getGcmId()).thenReturn("fcm-token"); + + arguments.add(Arguments.of(device, true)); + } + + { + // APNs token + final Device device = mock(Device.class); + when(device.getApnId()).thenReturn("apns-token"); + + arguments.add(Arguments.of(device, true)); + } + + return arguments; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java index 71a24b708..dd3efbf03 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java @@ -8,12 +8,9 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.google.i18n.phonenumbers.PhoneNumberUtil; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.time.ZoneId; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -21,8 +18,6 @@ import java.util.concurrent.CompletableFuture; import net.sourceforge.argparse4j.inf.Namespace; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler; @@ -124,7 +119,7 @@ class NotifyIdleDevicesCommandTest { when(eligibleDevice.getId()).thenReturn(Device.PRIMARY_ID); when(eligibleDevice.getApnId()).thenReturn("apns-token"); when(eligibleDevice.getLastSeen()) - .thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION).toEpochMilli()); + .thenReturn(CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli()); final Device ineligibleDevice = mock(Device.class); when(ineligibleDevice.getId()).thenReturn((byte) (Device.PRIMARY_ID + 1)); @@ -147,225 +142,4 @@ class NotifyIdleDevicesCommandTest { verify(idleDeviceNotificationScheduler, never()).scheduleNotification(eq(account), eq(ineligibleDevice), any()); } - - @ParameterizedTest - @MethodSource - void isDeviceEligible(final Account account, - final Device device, - final boolean mayHaveMessages, - final boolean mayHaveUrgentMessages, - final boolean expectEligible) { - - when(messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device)) - .thenReturn(CompletableFuture.completedFuture(mayHaveMessages)); - - when(messagesManager.mayHaveUrgentPersistedMessages(account.getIdentifier(IdentityType.ACI), device)) - .thenReturn(CompletableFuture.completedFuture(mayHaveUrgentMessages)); - - assertEquals(expectEligible, - NotifyIdleDevicesCommand.isDeviceEligible(account, device, messagesManager, Clock.fixed(CURRENT_TIME, ZoneId.systemDefault())).block()); - } - - private static List isDeviceEligible() { - final List arguments = new ArrayList<>(); - - final Account account = mock(Account.class); - when(account.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID()); - when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format( - PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164)); - - { - // Long-idle device with push token and messages - final Device device = mock(Device.class); - when(device.getApnId()).thenReturn("apns-token"); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, true, true, false)); - } - - { - // Long-idle device missing push token, but with messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, true, true, false)); - } - - { - // Long-idle device missing push token and messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, false, false, false)); - } - - { - // Long-idle device with push token, but no messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION).toEpochMilli()); - when(device.getApnId()).thenReturn("apns-token"); - - arguments.add(Arguments.of(account, device, false, false, true)); - } - - { - // Short-idle device with push token and urgent messages - final Device device = mock(Device.class); - when(device.getApnId()).thenReturn("apns-token"); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, true, true, true)); - } - - { - // Short-idle device with push token and only non-urgent messages - final Device device = mock(Device.class); - when(device.getApnId()).thenReturn("apns-token"); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, true, false, false)); - } - - { - // Short-idle device missing push token, but with urgent messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, true, true, false)); - } - - { - // Short-idle device missing push token and messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION).toEpochMilli()); - - arguments.add(Arguments.of(account, device, false, false, false)); - } - - { - // Short-idle device with push token, but no messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION).toEpochMilli()); - when(device.getApnId()).thenReturn("apns-token"); - - arguments.add(Arguments.of(account, device, false, false, false)); - } - - { - // Active device with push token and urgent messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); - when(device.getApnId()).thenReturn("apns-token"); - - arguments.add(Arguments.of(account, device, true, true, false)); - } - - { - // Active device missing push token, but with urgent messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); - - arguments.add(Arguments.of(account, device, true, true, false)); - } - - { - // Active device missing push token and messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); - - arguments.add(Arguments.of(account, device, false, false, false)); - } - - { - // Active device with push token, but no messages - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); - when(device.getApnId()).thenReturn("apns-token"); - - arguments.add(Arguments.of(account, device, false, false, false)); - } - - return arguments; - } - - @ParameterizedTest - @MethodSource - void isShortIdle(final Duration idleDuration, final boolean expectIdle) { - final Instant currentTime = Instant.now(); - final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault()); - - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(currentTime.minus(idleDuration).toEpochMilli()); - - assertEquals(expectIdle, NotifyIdleDevicesCommand.isShortIdle(device, clock)); - } - - private static List isShortIdle() { - return List.of( - Arguments.of(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION, true), - Arguments.of(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION.plusMillis(1), true), - Arguments.of(NotifyIdleDevicesCommand.MIN_SHORT_IDLE_DURATION.minusMillis(1), false), - Arguments.of(NotifyIdleDevicesCommand.MAX_SHORT_IDLE_DURATION, false), - Arguments.of(NotifyIdleDevicesCommand.MAX_SHORT_IDLE_DURATION.plusMillis(1), false), - Arguments.of(NotifyIdleDevicesCommand.MAX_SHORT_IDLE_DURATION.minusMillis(1), true) - ); - } - - @ParameterizedTest - @MethodSource - void isLongIdle(final Duration idleDuration, final boolean expectIdle) { - final Instant currentTime = Instant.now(); - final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault()); - - final Device device = mock(Device.class); - when(device.getLastSeen()).thenReturn(currentTime.minus(idleDuration).toEpochMilli()); - - assertEquals(expectIdle, NotifyIdleDevicesCommand.isLongIdle(device, clock)); - } - - private static List isLongIdle() { - return List.of( - Arguments.of(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION, true), - Arguments.of(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION.plusMillis(1), true), - Arguments.of(NotifyIdleDevicesCommand.MIN_LONG_IDLE_DURATION.minusMillis(1), false), - Arguments.of(NotifyIdleDevicesCommand.MAX_LONG_IDLE_DURATION, false), - Arguments.of(NotifyIdleDevicesCommand.MAX_LONG_IDLE_DURATION.plusMillis(1), false), - Arguments.of(NotifyIdleDevicesCommand.MAX_LONG_IDLE_DURATION.minusMillis(1), true) - ); - } - - @ParameterizedTest - @MethodSource - void hasPushToken(final Device device, final boolean expectHasPushToken) { - assertEquals(expectHasPushToken, NotifyIdleDevicesCommand.hasPushToken(device)); - } - - private static List hasPushToken() { - final List arguments = new ArrayList<>(); - - { - // No token at all - final Device device = mock(Device.class); - - arguments.add(Arguments.of(device, false)); - } - - { - // FCM token - final Device device = mock(Device.class); - when(device.getGcmId()).thenReturn("fcm-token"); - - arguments.add(Arguments.of(device, true)); - } - - { - // APNs token - final Device device = mock(Device.class); - when(device.getApnId()).thenReturn("apns-token"); - - arguments.add(Arguments.of(device, true)); - } - - return arguments; - } }