From ecf7e60d98455f0b203b6f7f2620dce8178e639d Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:41:19 -0400 Subject: [PATCH] Add an experiment for sending push notifications to idle devices that DO have pending messages --- .../textsecuregcm/WhisperServerService.java | 19 ++ .../IdleDevicePushNotificationExperiment.java | 138 ++++++++++++ ...tifyIdleDevicesWithMessagesExperiment.java | 76 +++++++ ...eDevicesWithMessagesExperimentFactory.java | 31 +++ ...eDevicePushNotificationExperimentTest.java | 199 ++++++++++++++++++ ...IdleDevicesWithMessagesExperimentTest.java | 171 +++++++++++++++ 6 files changed, 634 insertions(+) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperiment.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesWithMessagesExperimentFactory.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperimentTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperimentTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index beb982128..205cd7cb4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -251,8 +251,11 @@ 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.NotifyIdleDevicesWithMessagesExperimentFactory; import org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesWithoutMessagesCommand; import org.whispersystems.textsecuregcm.workers.ProcessScheduledJobsServiceCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand; @@ -263,6 +266,7 @@ 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.ZkParamsCommand; import org.whispersystems.websocket.WebSocketResourceProviderFactory; @@ -320,6 +324,21 @@ public void initialize(final Bootstrap bootstrap) { bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-idle-device-notification-jobs", "Processes scheduled jobs to send notifications to idle devices", new IdleDeviceNotificationSchedulerFactory())); + + bootstrap.addCommand( + new StartPushNotificationExperimentCommand<>("start-notify-idle-devices-with-messages-experiment", + "Start an experiment to send push notifications to idle devices with pending messages", + new NotifyIdleDevicesWithMessagesExperimentFactory())); + + bootstrap.addCommand( + new FinishPushNotificationExperimentCommand<>("finish-notify-idle-devices-with-messages-experiment", + "Finish an experiment to send push notifications to idle devices with pending messages", + new NotifyIdleDevicesWithMessagesExperimentFactory())); + + bootstrap.addCommand( + new DiscardPushNotificationExperimentSamplesCommand("discard-notify-idle-devices-with-messages-samples", + "Discard samples from the \"notify idle devices with messages\" experiment", + new NotifyIdleDevicesWithMessagesExperimentFactory())); } @Override diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java new file mode 100644 index 000000000..8f6b19c0b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java @@ -0,0 +1,138 @@ +package org.whispersystems.textsecuregcm.experiment; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +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; + +abstract class IdleDevicePushNotificationExperiment implements PushNotificationExperiment { + + private final Clock clock; + + private final Logger log = LoggerFactory.getLogger(getClass()); + + @VisibleForTesting + enum Population { + APNS_CONTROL, + APNS_EXPERIMENT, + FCM_CONTROL, + FCM_EXPERIMENT + } + + @VisibleForTesting + enum Outcome { + DELETED, + UNINSTALLED, + REACTIVATED, + 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; + } + + @VisibleForTesting + boolean hasPushToken(final Device device) { + // Exclude VOIP tokens since they have their own, distinct delivery mechanism + return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId()) && StringUtils.isBlank(device.getVoipApnId()); + } + + @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())) { + pushTokenType = DeviceLastSeenState.PushTokenType.FCM; + } else { + pushTokenType = null; + } + + return new DeviceLastSeenState(true, device.getCreated(), hasPushToken(device), device.getLastSeen(), pushTokenType); + } else { + return DeviceLastSeenState.MISSING_DEVICE_STATE; + } + } + + @Override + public void analyzeResults(final Flux> samples) { + final Map> contingencyTable = new EnumMap<>(Population.class); + + samples.doOnNext(sample -> + contingencyTable.computeIfAbsent(getPopulation(sample), ignored -> new EnumMap<>(Outcome.class)) + .merge(getOutcome(sample), 1, Integer::sum)) + .then() + .block(); + + final StringBuilder reportBuilder = new StringBuilder("population,deleted,uninstalled,reactivated,unchanged\n"); + + for (final Population population : Population.values()) { + final Map countsByOutcome = contingencyTable.getOrDefault(population, Collections.emptyMap()); + + reportBuilder.append(population.name()); + reportBuilder.append(","); + reportBuilder.append(countsByOutcome.getOrDefault(Outcome.DELETED, 0)); + reportBuilder.append(","); + reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNINSTALLED, 0)); + reportBuilder.append(","); + reportBuilder.append(countsByOutcome.getOrDefault(Outcome.REACTIVATED, 0)); + reportBuilder.append(","); + reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNCHANGED, 0)); + reportBuilder.append("\n"); + } + + log.info(reportBuilder.toString()); + } + + @VisibleForTesting + static Population getPopulation(final PushNotificationExperimentSample sample) { + assert sample.initialState() != null && sample.initialState().pushTokenType() != null; + + return switch (sample.initialState().pushTokenType()) { + case APNS -> sample.inExperimentGroup() ? Population.APNS_EXPERIMENT : Population.APNS_CONTROL; + case FCM -> sample.inExperimentGroup() ? Population.FCM_EXPERIMENT : Population.FCM_CONTROL; + }; + } + + @VisibleForTesting + static Outcome getOutcome(final PushNotificationExperimentSample sample) { + final Outcome outcome; + + assert sample.finalState() != null; + + if (!sample.finalState().deviceExists() || sample.initialState().createdAtMillis() != sample.finalState().createdAtMillis()) { + outcome = Outcome.DELETED; + } else if (!sample.finalState().hasPushToken()) { + outcome = Outcome.UNINSTALLED; + } else if (sample.initialState().lastSeenMillis() != sample.finalState().lastSeenMillis()) { + outcome = Outcome.REACTIVATED; + } else { + outcome = Outcome.UNCHANGED; + } + + return outcome; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperiment.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperiment.java new file mode 100644 index 000000000..9bc02c481 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperiment.java @@ -0,0 +1,76 @@ +package org.whispersystems.textsecuregcm.experiment; + +import com.google.common.annotations.VisibleForTesting; +import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.MessagesManager; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalTime; +import java.util.concurrent.CompletableFuture; + +public class NotifyIdleDevicesWithMessagesExperiment extends IdleDevicePushNotificationExperiment { + + private final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler; + private final MessagesManager messagesManager; + + @VisibleForTesting + static final Duration MIN_IDLE_DURATION = Duration.ofDays(3); + + @VisibleForTesting + static final Duration MAX_IDLE_DURATION = Duration.ofDays(14); + + @VisibleForTesting + static final LocalTime PREFERRED_NOTIFICATION_TIME = LocalTime.of(14, 0); + + public NotifyIdleDevicesWithMessagesExperiment(final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler, + final MessagesManager messagesManager, + final Clock clock) { + + super(clock); + + this.idleDeviceNotificationScheduler = idleDeviceNotificationScheduler; + this.messagesManager = messagesManager; + } + + @Override + protected Duration getMinIdleDuration() { + return MIN_IDLE_DURATION; + } + + @Override + protected Duration getMaxIdleDuration() { + return MAX_IDLE_DURATION; + } + + @Override + public String getExperimentName() { + return "notify-idle-devices-with-messages"; + } + + @Override + public CompletableFuture isDeviceEligible(final Account account, final Device device) { + + if (!hasPushToken(device)) { + return CompletableFuture.completedFuture(false); + } + + if (!isIdle(device)) { + return CompletableFuture.completedFuture(false); + } + + return messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device); + } + + @Override + public Class getStateClass() { + return DeviceLastSeenState.class; + } + + @Override + public CompletableFuture applyExperimentTreatment(final Account account, final Device device) { + return idleDeviceNotificationScheduler.scheduleNotification(account, device, PREFERRED_NOTIFICATION_TIME); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesWithMessagesExperimentFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesWithMessagesExperimentFactory.java new file mode 100644 index 000000000..9aab3afd5 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesWithMessagesExperimentFactory.java @@ -0,0 +1,31 @@ +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.NotifyIdleDevicesWithMessagesExperiment; +import org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment; +import org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler; +import java.time.Clock; + +public class NotifyIdleDevicesWithMessagesExperimentFactory 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 NotifyIdleDevicesWithMessagesExperiment(new IdleDeviceNotificationScheduler( + commandDependencies.accountsManager(), + commandDependencies.pushNotificationManager(), + commandDependencies.dynamoDbAsyncClient(), + tableConfiguration.getTableName(), + tableConfiguration.getExpiration(), + clock), + commandDependencies.messagesManager(), + clock); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperimentTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperimentTest.java new file mode 100644 index 000000000..b46a92915 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperimentTest.java @@ -0,0 +1,199 @@ +package org.whispersystems.textsecuregcm.experiment; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +abstract class IdleDevicePushNotificationExperimentTest { + + protected static final Instant CURRENT_TIME = Instant.now(); + + protected abstract IdleDevicePushNotificationExperiment getExperiment(); + + @ParameterizedTest + @MethodSource + void hasPushToken(final Device device, final boolean expectHasPushToken) { + assertEquals(expectHasPushToken, getExperiment().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)); + } + + { + // APNs VOIP token + final Device device = mock(Device.class); + when(device.getApnId()).thenReturn("apns-token"); + when(device.getVoipApnId()).thenReturn("apns-voip-token"); + + arguments.add(Arguments.of(device, false)); + } + + return arguments; + } + + @Test + void getState() { + final IdleDevicePushNotificationExperiment experiment = getExperiment(); + + assertEquals(DeviceLastSeenState.MISSING_DEVICE_STATE, experiment.getState(null, null)); + assertEquals(DeviceLastSeenState.MISSING_DEVICE_STATE, experiment.getState(mock(Account.class), null)); + + final long createdAtMillis = CURRENT_TIME.minus(Duration.ofDays(14)).toEpochMilli(); + + { + final Device apnsDevice = mock(Device.class); + when(apnsDevice.getApnId()).thenReturn("apns-token"); + when(apnsDevice.getCreated()).thenReturn(createdAtMillis); + when(apnsDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + + assertEquals( + new DeviceLastSeenState(true, createdAtMillis, true, CURRENT_TIME.toEpochMilli(), DeviceLastSeenState.PushTokenType.APNS), + experiment.getState(mock(Account.class), apnsDevice)); + } + + { + final Device fcmDevice = mock(Device.class); + when(fcmDevice.getGcmId()).thenReturn("fcm-token"); + when(fcmDevice.getCreated()).thenReturn(createdAtMillis); + when(fcmDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + + assertEquals( + new DeviceLastSeenState(true, createdAtMillis, true, CURRENT_TIME.toEpochMilli(), DeviceLastSeenState.PushTokenType.FCM), + experiment.getState(mock(Account.class), fcmDevice)); + } + + { + final Device noTokenDevice = mock(Device.class); + when(noTokenDevice.getCreated()).thenReturn(createdAtMillis); + when(noTokenDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + + assertEquals( + new DeviceLastSeenState(true, createdAtMillis, false, CURRENT_TIME.toEpochMilli(), null), + experiment.getState(mock(Account.class), noTokenDevice)); + } + } + @ParameterizedTest + @MethodSource + void getPopulation(final boolean inExperimentGroup, + final DeviceLastSeenState.PushTokenType tokenType, + final IdleDevicePushNotificationExperiment.Population expectedPopulation) { + + final DeviceLastSeenState state = new DeviceLastSeenState(true, 0, true, 0, tokenType); + final PushNotificationExperimentSample sample = + new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, inExperimentGroup, state, state); + + assertEquals(expectedPopulation, IdleDevicePushNotificationExperiment.getPopulation(sample)); + } + + private static List getPopulation() { + return List.of( + Arguments.of(true, DeviceLastSeenState.PushTokenType.APNS, + IdleDevicePushNotificationExperiment.Population.APNS_EXPERIMENT), + + Arguments.of(false, DeviceLastSeenState.PushTokenType.APNS, + IdleDevicePushNotificationExperiment.Population.APNS_CONTROL), + + Arguments.of(true, DeviceLastSeenState.PushTokenType.FCM, + IdleDevicePushNotificationExperiment.Population.FCM_EXPERIMENT), + + Arguments.of(false, DeviceLastSeenState.PushTokenType.FCM, + IdleDevicePushNotificationExperiment.Population.FCM_CONTROL) + ); + } + + @ParameterizedTest + @MethodSource + void getOutcome(final DeviceLastSeenState initialState, + final DeviceLastSeenState finalState, + final IdleDevicePushNotificationExperiment.Outcome expectedOutcome) { + + final PushNotificationExperimentSample sample = + new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, true, initialState, finalState); + + assertEquals(expectedOutcome, IdleDevicePushNotificationExperiment.getOutcome(sample)); + } + + private static List getOutcome() { + return List.of( + // Device no longer exists + Arguments.of( + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + DeviceLastSeenState.MISSING_DEVICE_STATE, + IdleDevicePushNotificationExperiment.Outcome.DELETED + ), + + // Device re-registered (i.e. "created" timestamp changed) + Arguments.of( + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + new DeviceLastSeenState(true, 1, true, 1, DeviceLastSeenState.PushTokenType.APNS), + IdleDevicePushNotificationExperiment.Outcome.DELETED + ), + + // Device has lost push tokens + Arguments.of( + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + new DeviceLastSeenState(true, 0, false, 0, DeviceLastSeenState.PushTokenType.APNS), + IdleDevicePushNotificationExperiment.Outcome.UNINSTALLED + ), + + // Device reactivated + Arguments.of( + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + new DeviceLastSeenState(true, 0, true, 1, DeviceLastSeenState.PushTokenType.APNS), + IdleDevicePushNotificationExperiment.Outcome.REACTIVATED + ), + + // No change + Arguments.of( + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + IdleDevicePushNotificationExperiment.Outcome.UNCHANGED + ) + ); + } + + @Test + void analyzeResults() { + assertDoesNotThrow(() -> getExperiment().analyzeResults( + Flux.just(new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, true, + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS), + new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS))))); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperimentTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperimentTest.java new file mode 100644 index 000000000..fc548469f --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperimentTest.java @@ -0,0 +1,171 @@ +package org.whispersystems.textsecuregcm.experiment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +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.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.api.Test; +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.push.IdleDeviceNotificationScheduler; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.MessagesManager; + +class NotifyIdleDevicesWithMessagesExperimentTest extends IdleDevicePushNotificationExperimentTest { + + private IdleDeviceNotificationScheduler idleDeviceNotificationScheduler; + private MessagesManager messagesManager; + + private NotifyIdleDevicesWithMessagesExperiment experiment; + + @BeforeEach + void setUp() { + idleDeviceNotificationScheduler = mock(IdleDeviceNotificationScheduler.class); + messagesManager = mock(MessagesManager.class); + + experiment = new NotifyIdleDevicesWithMessagesExperiment(idleDeviceNotificationScheduler, + messagesManager, + Clock.fixed(CURRENT_TIME, ZoneId.systemDefault())); + } + + @Override + protected IdleDevicePushNotificationExperiment getExperiment() { + return experiment; + } + + @ParameterizedTest + @MethodSource + void isDeviceEligible(final Account account, + final Device device, + final boolean mayHaveMessages, + final boolean expectEligible) { + + when(messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device)) + .thenReturn(CompletableFuture.completedFuture(mayHaveMessages)); + + assertEquals(expectEligible, experiment.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)); + + { + // 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(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, true)); + } + + { + // Idle device missing push token, but with messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, true, false)); + } + + { + // Idle device missing push token and messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION).toEpochMilli()); + + arguments.add(Arguments.of(account, device, false, false)); + } + + { + // Idle device with push token, but no messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION).toEpochMilli()); + when(device.getApnId()).thenReturn("apns-token"); + + arguments.add(Arguments.of(account, device, false, false)); + } + + { + // Active device with push token and 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, false)); + } + + { + // Active device missing push token, but with messages + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli()); + + arguments.add(Arguments.of(account, device, 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)); + } + + { + // 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)); + } + + return arguments; + } + + @ParameterizedTest + @MethodSource + void isIdle(final Duration idleDuration, final boolean expectIdle) { + final Device device = mock(Device.class); + when(device.getLastSeen()).thenReturn(CURRENT_TIME.minus(idleDuration).toEpochMilli()); + + assertEquals(expectIdle, experiment.isIdle(device)); + } + + private static List isIdle() { + return List.of( + Arguments.of(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION, true), + Arguments.of(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION.plusMillis(1), true), + Arguments.of(NotifyIdleDevicesWithMessagesExperiment.MIN_IDLE_DURATION.minusMillis(1), false), + Arguments.of(NotifyIdleDevicesWithMessagesExperiment.MAX_IDLE_DURATION, false), + Arguments.of(NotifyIdleDevicesWithMessagesExperiment.MAX_IDLE_DURATION.plusMillis(1), false), + Arguments.of(NotifyIdleDevicesWithMessagesExperiment.MAX_IDLE_DURATION.minusMillis(1), true) + ); + } + + @Test + void applyExperimentTreatment() { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + experiment.applyExperimentTreatment(account, device); + + verify(idleDeviceNotificationScheduler) + .scheduleNotification(account, device, NotifyIdleDevicesWithMessagesExperiment.PREFERRED_NOTIFICATION_TIME); + } +}