-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add an experiment for sending push notifications to idle devices that…
… DO have pending messages
- Loading branch information
1 parent
68ddc07
commit ecf7e60
Showing
6 changed files
with
634 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
...ava/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DeviceLastSeenState> { | ||
|
||
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<PushNotificationExperimentSample<DeviceLastSeenState>> samples) { | ||
final Map<Population, Map<Outcome, Integer>> 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<Outcome, Integer> 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<DeviceLastSeenState> 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<DeviceLastSeenState> 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; | ||
} | ||
} |
76 changes: 76 additions & 0 deletions
76
.../org/whispersystems/textsecuregcm/experiment/NotifyIdleDevicesWithMessagesExperiment.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Boolean> 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<DeviceLastSeenState> getStateClass() { | ||
return DeviceLastSeenState.class; | ||
} | ||
|
||
@Override | ||
public CompletableFuture<Void> applyExperimentTreatment(final Account account, final Device device) { | ||
return idleDeviceNotificationScheduler.scheduleNotification(account, device, PREFERRED_NOTIFICATION_TIME); | ||
} | ||
} |
31 changes: 31 additions & 0 deletions
31
.../whispersystems/textsecuregcm/workers/NotifyIdleDevicesWithMessagesExperimentFactory.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DeviceLastSeenState> { | ||
|
||
@Override | ||
public PushNotificationExperiment<DeviceLastSeenState> 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); | ||
} | ||
} |
Oops, something went wrong.