Skip to content

Commit ecf7e60

Browse files
authored
Add an experiment for sending push notifications to idle devices that DO have pending messages
1 parent 68ddc07 commit ecf7e60

File tree

6 files changed

+634
-0
lines changed

6 files changed

+634
-0
lines changed

service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,11 @@
251251
import org.whispersystems.textsecuregcm.workers.CertificateCommand;
252252
import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand;
253253
import org.whispersystems.textsecuregcm.workers.DeleteUserCommand;
254+
import org.whispersystems.textsecuregcm.workers.DiscardPushNotificationExperimentSamplesCommand;
255+
import org.whispersystems.textsecuregcm.workers.FinishPushNotificationExperimentCommand;
254256
import org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerFactory;
255257
import org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand;
258+
import org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesWithMessagesExperimentFactory;
256259
import org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesWithoutMessagesCommand;
257260
import org.whispersystems.textsecuregcm.workers.ProcessScheduledJobsServiceCommand;
258261
import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;
@@ -263,6 +266,7 @@
263266
import org.whispersystems.textsecuregcm.workers.ServerVersionCommand;
264267
import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask;
265268
import org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand;
269+
import org.whispersystems.textsecuregcm.workers.StartPushNotificationExperimentCommand;
266270
import org.whispersystems.textsecuregcm.workers.UnlinkDeviceCommand;
267271
import org.whispersystems.textsecuregcm.workers.ZkParamsCommand;
268272
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
@@ -320,6 +324,21 @@ public void initialize(final Bootstrap<WhisperServerConfiguration> bootstrap) {
320324
bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-idle-device-notification-jobs",
321325
"Processes scheduled jobs to send notifications to idle devices",
322326
new IdleDeviceNotificationSchedulerFactory()));
327+
328+
bootstrap.addCommand(
329+
new StartPushNotificationExperimentCommand<>("start-notify-idle-devices-with-messages-experiment",
330+
"Start an experiment to send push notifications to idle devices with pending messages",
331+
new NotifyIdleDevicesWithMessagesExperimentFactory()));
332+
333+
bootstrap.addCommand(
334+
new FinishPushNotificationExperimentCommand<>("finish-notify-idle-devices-with-messages-experiment",
335+
"Finish an experiment to send push notifications to idle devices with pending messages",
336+
new NotifyIdleDevicesWithMessagesExperimentFactory()));
337+
338+
bootstrap.addCommand(
339+
new DiscardPushNotificationExperimentSamplesCommand("discard-notify-idle-devices-with-messages-samples",
340+
"Discard samples from the \"notify idle devices with messages\" experiment",
341+
new NotifyIdleDevicesWithMessagesExperimentFactory()));
323342
}
324343

325344
@Override
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package org.whispersystems.textsecuregcm.experiment;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import org.apache.commons.lang3.StringUtils;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.whispersystems.textsecuregcm.storage.Account;
8+
import org.whispersystems.textsecuregcm.storage.Device;
9+
import reactor.core.publisher.Flux;
10+
import javax.annotation.Nullable;
11+
import java.time.Clock;
12+
import java.time.Duration;
13+
import java.time.Instant;
14+
import java.util.Collections;
15+
import java.util.EnumMap;
16+
import java.util.Map;
17+
18+
abstract class IdleDevicePushNotificationExperiment implements PushNotificationExperiment<DeviceLastSeenState> {
19+
20+
private final Clock clock;
21+
22+
private final Logger log = LoggerFactory.getLogger(getClass());
23+
24+
@VisibleForTesting
25+
enum Population {
26+
APNS_CONTROL,
27+
APNS_EXPERIMENT,
28+
FCM_CONTROL,
29+
FCM_EXPERIMENT
30+
}
31+
32+
@VisibleForTesting
33+
enum Outcome {
34+
DELETED,
35+
UNINSTALLED,
36+
REACTIVATED,
37+
UNCHANGED
38+
}
39+
40+
protected IdleDevicePushNotificationExperiment(final Clock clock) {
41+
this.clock = clock;
42+
}
43+
44+
protected abstract Duration getMinIdleDuration();
45+
46+
protected abstract Duration getMaxIdleDuration();
47+
48+
@VisibleForTesting
49+
boolean isIdle(final Device device) {
50+
final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant());
51+
52+
return idleDuration.compareTo(getMinIdleDuration()) >= 0 && idleDuration.compareTo(getMaxIdleDuration()) < 0;
53+
}
54+
55+
@VisibleForTesting
56+
boolean hasPushToken(final Device device) {
57+
// Exclude VOIP tokens since they have their own, distinct delivery mechanism
58+
return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId()) && StringUtils.isBlank(device.getVoipApnId());
59+
}
60+
61+
@Override
62+
public DeviceLastSeenState getState(@Nullable final Account account, @Nullable final Device device) {
63+
if (account != null && device != null) {
64+
final DeviceLastSeenState.PushTokenType pushTokenType;
65+
66+
if (StringUtils.isNotBlank(device.getApnId())) {
67+
pushTokenType = DeviceLastSeenState.PushTokenType.APNS;
68+
} else if (StringUtils.isNotBlank(device.getGcmId())) {
69+
pushTokenType = DeviceLastSeenState.PushTokenType.FCM;
70+
} else {
71+
pushTokenType = null;
72+
}
73+
74+
return new DeviceLastSeenState(true, device.getCreated(), hasPushToken(device), device.getLastSeen(), pushTokenType);
75+
} else {
76+
return DeviceLastSeenState.MISSING_DEVICE_STATE;
77+
}
78+
}
79+
80+
@Override
81+
public void analyzeResults(final Flux<PushNotificationExperimentSample<DeviceLastSeenState>> samples) {
82+
final Map<Population, Map<Outcome, Integer>> contingencyTable = new EnumMap<>(Population.class);
83+
84+
samples.doOnNext(sample ->
85+
contingencyTable.computeIfAbsent(getPopulation(sample), ignored -> new EnumMap<>(Outcome.class))
86+
.merge(getOutcome(sample), 1, Integer::sum))
87+
.then()
88+
.block();
89+
90+
final StringBuilder reportBuilder = new StringBuilder("population,deleted,uninstalled,reactivated,unchanged\n");
91+
92+
for (final Population population : Population.values()) {
93+
final Map<Outcome, Integer> countsByOutcome = contingencyTable.getOrDefault(population, Collections.emptyMap());
94+
95+
reportBuilder.append(population.name());
96+
reportBuilder.append(",");
97+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.DELETED, 0));
98+
reportBuilder.append(",");
99+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNINSTALLED, 0));
100+
reportBuilder.append(",");
101+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.REACTIVATED, 0));
102+
reportBuilder.append(",");
103+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNCHANGED, 0));
104+
reportBuilder.append("\n");
105+
}
106+
107+
log.info(reportBuilder.toString());
108+
}
109+
110+
@VisibleForTesting
111+
static Population getPopulation(final PushNotificationExperimentSample<DeviceLastSeenState> sample) {
112+
assert sample.initialState() != null && sample.initialState().pushTokenType() != null;
113+
114+
return switch (sample.initialState().pushTokenType()) {
115+
case APNS -> sample.inExperimentGroup() ? Population.APNS_EXPERIMENT : Population.APNS_CONTROL;
116+
case FCM -> sample.inExperimentGroup() ? Population.FCM_EXPERIMENT : Population.FCM_CONTROL;
117+
};
118+
}
119+
120+
@VisibleForTesting
121+
static Outcome getOutcome(final PushNotificationExperimentSample<DeviceLastSeenState> sample) {
122+
final Outcome outcome;
123+
124+
assert sample.finalState() != null;
125+
126+
if (!sample.finalState().deviceExists() || sample.initialState().createdAtMillis() != sample.finalState().createdAtMillis()) {
127+
outcome = Outcome.DELETED;
128+
} else if (!sample.finalState().hasPushToken()) {
129+
outcome = Outcome.UNINSTALLED;
130+
} else if (sample.initialState().lastSeenMillis() != sample.finalState().lastSeenMillis()) {
131+
outcome = Outcome.REACTIVATED;
132+
} else {
133+
outcome = Outcome.UNCHANGED;
134+
}
135+
136+
return outcome;
137+
}
138+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.whispersystems.textsecuregcm.experiment;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import org.whispersystems.textsecuregcm.identity.IdentityType;
5+
import org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler;
6+
import org.whispersystems.textsecuregcm.storage.Account;
7+
import org.whispersystems.textsecuregcm.storage.Device;
8+
import org.whispersystems.textsecuregcm.storage.MessagesManager;
9+
import java.time.Clock;
10+
import java.time.Duration;
11+
import java.time.LocalTime;
12+
import java.util.concurrent.CompletableFuture;
13+
14+
public class NotifyIdleDevicesWithMessagesExperiment extends IdleDevicePushNotificationExperiment {
15+
16+
private final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler;
17+
private final MessagesManager messagesManager;
18+
19+
@VisibleForTesting
20+
static final Duration MIN_IDLE_DURATION = Duration.ofDays(3);
21+
22+
@VisibleForTesting
23+
static final Duration MAX_IDLE_DURATION = Duration.ofDays(14);
24+
25+
@VisibleForTesting
26+
static final LocalTime PREFERRED_NOTIFICATION_TIME = LocalTime.of(14, 0);
27+
28+
public NotifyIdleDevicesWithMessagesExperiment(final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler,
29+
final MessagesManager messagesManager,
30+
final Clock clock) {
31+
32+
super(clock);
33+
34+
this.idleDeviceNotificationScheduler = idleDeviceNotificationScheduler;
35+
this.messagesManager = messagesManager;
36+
}
37+
38+
@Override
39+
protected Duration getMinIdleDuration() {
40+
return MIN_IDLE_DURATION;
41+
}
42+
43+
@Override
44+
protected Duration getMaxIdleDuration() {
45+
return MAX_IDLE_DURATION;
46+
}
47+
48+
@Override
49+
public String getExperimentName() {
50+
return "notify-idle-devices-with-messages";
51+
}
52+
53+
@Override
54+
public CompletableFuture<Boolean> isDeviceEligible(final Account account, final Device device) {
55+
56+
if (!hasPushToken(device)) {
57+
return CompletableFuture.completedFuture(false);
58+
}
59+
60+
if (!isIdle(device)) {
61+
return CompletableFuture.completedFuture(false);
62+
}
63+
64+
return messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device);
65+
}
66+
67+
@Override
68+
public Class<DeviceLastSeenState> getStateClass() {
69+
return DeviceLastSeenState.class;
70+
}
71+
72+
@Override
73+
public CompletableFuture<Void> applyExperimentTreatment(final Account account, final Device device) {
74+
return idleDeviceNotificationScheduler.scheduleNotification(account, device, PREFERRED_NOTIFICATION_TIME);
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.whispersystems.textsecuregcm.workers;
2+
3+
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
4+
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
5+
import org.whispersystems.textsecuregcm.experiment.DeviceLastSeenState;
6+
import org.whispersystems.textsecuregcm.experiment.NotifyIdleDevicesWithMessagesExperiment;
7+
import org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;
8+
import org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler;
9+
import java.time.Clock;
10+
11+
public class NotifyIdleDevicesWithMessagesExperimentFactory implements PushNotificationExperimentFactory<DeviceLastSeenState> {
12+
13+
@Override
14+
public PushNotificationExperiment<DeviceLastSeenState> buildExperiment(final CommandDependencies commandDependencies,
15+
final WhisperServerConfiguration configuration) {
16+
17+
final DynamoDbTables.TableWithExpiration tableConfiguration = configuration.getDynamoDbTables().getScheduledJobs();
18+
19+
final Clock clock = Clock.systemUTC();
20+
21+
return new NotifyIdleDevicesWithMessagesExperiment(new IdleDeviceNotificationScheduler(
22+
commandDependencies.accountsManager(),
23+
commandDependencies.pushNotificationManager(),
24+
commandDependencies.dynamoDbAsyncClient(),
25+
tableConfiguration.getTableName(),
26+
tableConfiguration.getExpiration(),
27+
clock),
28+
commandDependencies.messagesManager(),
29+
clock);
30+
}
31+
}

0 commit comments

Comments
 (0)