Replace device creation timestamps with registration IDs in experiment logic

This commit is contained in:
Katherine
2025-07-23 10:24:28 -04:00
committed by GitHub
parent 876bf15a11
commit 74c7e49cea
3 changed files with 19 additions and 13 deletions
@@ -3,7 +3,11 @@ package org.whispersystems.textsecuregcm.experiment;
import javax.annotation.Nullable;
public record DeviceLastSeenState(boolean deviceExists,
long createdAtMillis,
// Registration IDs are not guaranteed to be unique across devices and re-registrations.
// However, for this use case, we accept the possibility of collisions in order to
// avoid storing plaintext device creation timestamps on the server.
// This tradeoff is intentional and aligned with our privacy goals.
int registrationId,
boolean hasPushToken,
long lastSeenMillis,
@Nullable PushTokenType pushTokenType) {
@@ -4,6 +4,7 @@ import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.workers.IdleWakeupEligibilityChecker;
@@ -64,7 +65,7 @@ abstract class IdleDevicePushNotificationExperiment implements PushNotificationE
} else {
pushTokenType = null;
}
return new DeviceLastSeenState(true, device.getCreated(), hasPushToken(device), device.getLastSeen(), pushTokenType);
return new DeviceLastSeenState(true, device.getRegistrationId(IdentityType.ACI), hasPushToken(device), device.getLastSeen(), pushTokenType);
} else {
return DeviceLastSeenState.MISSING_DEVICE_STATE;
}
@@ -116,7 +117,7 @@ abstract class IdleDevicePushNotificationExperiment implements PushNotificationE
assert sample.finalState() != null;
if (!sample.finalState().deviceExists() || sample.initialState().createdAtMillis() != sample.finalState().createdAtMillis()) {
if (!sample.finalState().deviceExists() || sample.initialState().registrationId() != sample.finalState().registrationId()) {
outcome = Outcome.DELETED;
} else if (!sample.finalState().hasPushToken()) {
outcome = Outcome.UNINSTALLED;
@@ -4,6 +4,7 @@ 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.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device;
import reactor.core.publisher.Flux;
@@ -66,37 +67,37 @@ abstract class IdleDevicePushNotificationExperimentTest {
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 int registrationId = 123;
{
final Device apnsDevice = mock(Device.class);
when(apnsDevice.getApnId()).thenReturn("apns-token");
when(apnsDevice.getCreated()).thenReturn(createdAtMillis);
when(apnsDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);
when(apnsDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());
assertEquals(
new DeviceLastSeenState(true, createdAtMillis, true, CURRENT_TIME.toEpochMilli(), DeviceLastSeenState.PushTokenType.APNS),
new DeviceLastSeenState(true, registrationId, 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.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);
when(fcmDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());
assertEquals(
new DeviceLastSeenState(true, createdAtMillis, true, CURRENT_TIME.toEpochMilli(), DeviceLastSeenState.PushTokenType.FCM),
new DeviceLastSeenState(true, registrationId, 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.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);
when(noTokenDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());
assertEquals(
new DeviceLastSeenState(true, createdAtMillis, false, CURRENT_TIME.toEpochMilli(), null),
new DeviceLastSeenState(true, registrationId, false, CURRENT_TIME.toEpochMilli(), null),
experiment.getState(mock(Account.class), noTokenDevice));
}
}
@@ -150,10 +151,10 @@ abstract class IdleDevicePushNotificationExperimentTest {
IdleDevicePushNotificationExperiment.Outcome.DELETED
),
// Device re-registered (i.e. "created" timestamp changed)
// Device re-registered (i.e. registration ID changed)
Arguments.of(
new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),
new DeviceLastSeenState(true, 1, true, 1, DeviceLastSeenState.PushTokenType.APNS),
new DeviceLastSeenState(true, 123, true, 0, DeviceLastSeenState.PushTokenType.APNS),
new DeviceLastSeenState(true, 1234, true, 1, DeviceLastSeenState.PushTokenType.APNS),
IdleDevicePushNotificationExperiment.Outcome.DELETED
),