Introduce a utility class for finding reasonable times to send push notifications
This commit is contained in:
parent
eac75aad03
commit
5e8a0b2cfa
|
@ -393,6 +393,14 @@
|
|||
<artifactId>libphonenumber</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Provides tools for mapping phone numbers to time zones, which is helpful for scheduling push notifications
|
||||
during waking hours -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>geocoder</artifactId>
|
||||
<version>2.234</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.argparse4j</groupId>
|
||||
<artifactId>argparse4j</artifactId>
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
package org.whispersystems.textsecuregcm.scheduler;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberToTimeZonesMapper;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public class SchedulingUtil {
|
||||
|
||||
/**
|
||||
* Gets a present or future time at which to send a notification to a device associated with the given account. This
|
||||
* is mainly intended to facilitate scheduling notifications such that they arrive during a recipient's waking hours.
|
||||
* <p/>
|
||||
* This method will attempt to use a timezone derived from the account's phone number to choose an appropriate time
|
||||
* to send a notification. If a timezone cannot be derived from the account's phone number, then this method will use
|
||||
* the account's creation time as a hint. As an example, if the account was created at 07:17 in the server's local
|
||||
* time, we can assume that the account holder was awake at that time of day, and it's likely a safe time to send a
|
||||
* notification in the absence of other hints.
|
||||
*
|
||||
* @param account the account that will receive the notification
|
||||
* @param preferredTime the preferred local time (e.g. "noon") at which to deliver the notification
|
||||
* @param clock a source of the current time
|
||||
*
|
||||
* @return the next time in the present or future at which to send a notification for the target account
|
||||
*/
|
||||
public static Instant getNextRecommendedNotificationTime(final Account account,
|
||||
final LocalTime preferredTime,
|
||||
final Clock clock) {
|
||||
|
||||
final ZonedDateTime candidateNotificationTime = getZoneOffset(account, clock)
|
||||
.map(zoneOffset -> ZonedDateTime.now(zoneOffset).with(preferredTime))
|
||||
.orElseGet(() -> {
|
||||
// We couldn't find a reasonable timezone for the account for some reason, so make an educated guess at a
|
||||
// reasonable time to send a notification based on the account's creation time.
|
||||
final Instant accountCreation = Instant.ofEpochMilli(account.getPrimaryDevice().getCreated());
|
||||
final LocalTime accountCreationLocalTime = LocalTime.ofInstant(accountCreation, ZoneId.systemDefault());
|
||||
|
||||
return ZonedDateTime.now(ZoneId.systemDefault()).with(accountCreationLocalTime);
|
||||
});
|
||||
|
||||
if (candidateNotificationTime.toInstant().isBefore(clock.instant())) {
|
||||
// We've missed our opportunity today, so go for the same time tomorrow
|
||||
return candidateNotificationTime.plusDays(1).toInstant();
|
||||
} else {
|
||||
// The best time to send a notification hasn't happened yet today
|
||||
return candidateNotificationTime.toInstant();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static Optional<ZoneOffset> getZoneOffset(final Account account, final Clock clock) {
|
||||
try {
|
||||
final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(account.getNumber(), null);
|
||||
|
||||
final List<String> timeZonesForNumber =
|
||||
PhoneNumberToTimeZonesMapper.getInstance().getTimeZonesForNumber(phoneNumber);
|
||||
|
||||
if (timeZonesForNumber.equals(List.of(PhoneNumberToTimeZonesMapper.getUnknownTimeZone()))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final List<ZoneOffset> sortedZoneOffsets = timeZonesForNumber
|
||||
.stream()
|
||||
.map(id -> {
|
||||
try {
|
||||
return ZoneId.of(id);
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.map(ZoneId::getRules)
|
||||
.distinct()
|
||||
.map(zoneRules -> zoneRules.getOffset(clock.instant()))
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
return Optional.of(sortedZoneOffsets.get(sortedZoneOffsets.size() / 2));
|
||||
} catch (final NumberParseException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package org.whispersystems.textsecuregcm.scheduler;
|
||||
|
||||
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.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
class SchedulingUtilTest {
|
||||
|
||||
@Test
|
||||
void getNextRecommendedNotificationTime() {
|
||||
{
|
||||
final Account account = mock(Account.class);
|
||||
|
||||
// The account has a phone number that can be resolved to a region with known timezones
|
||||
when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(
|
||||
PhoneNumberUtil.getInstance().getExampleNumber("DE"), PhoneNumberUtil.PhoneNumberFormat.E164));
|
||||
|
||||
final ZoneId berlinZoneId = ZoneId.of("Europe/Berlin");
|
||||
final ZonedDateTime beforeNotificationTime = ZonedDateTime.now(berlinZoneId).with(LocalTime.of(13, 0));
|
||||
|
||||
assertEquals(
|
||||
beforeNotificationTime.with(LocalTime.of(14, 0)).toInstant(),
|
||||
SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),
|
||||
Clock.fixed(beforeNotificationTime.toInstant(), berlinZoneId)));
|
||||
|
||||
final ZonedDateTime afterNotificationTime = ZonedDateTime.now(berlinZoneId).with(LocalTime.of(15, 0));
|
||||
|
||||
assertEquals(
|
||||
afterNotificationTime.with(LocalTime.of(14, 0)).plusDays(1).toInstant(),
|
||||
SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),
|
||||
Clock.fixed(afterNotificationTime.toInstant(), berlinZoneId)));
|
||||
}
|
||||
|
||||
{
|
||||
final Account account = mock(Account.class);
|
||||
final Device primaryDevice = mock(Device.class);
|
||||
|
||||
// The account does not have a phone number that can be connected to a region/time zone
|
||||
when(account.getNumber()).thenReturn("Not a parseable number");
|
||||
when(account.getPrimaryDevice()).thenReturn(primaryDevice);
|
||||
when(primaryDevice.getCreated())
|
||||
.thenReturn(ZonedDateTime.of(2024, 7, 10, 9, 53, 12, 0, ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
|
||||
final ZonedDateTime beforeNotificationTime = ZonedDateTime.now(ZoneId.systemDefault()).with(LocalTime.of(9, 0));
|
||||
|
||||
assertEquals(
|
||||
beforeNotificationTime.with(LocalTime.of(9, 53, 12)).toInstant(),
|
||||
SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),
|
||||
Clock.fixed(beforeNotificationTime.toInstant(), ZoneId.systemDefault())));
|
||||
|
||||
final ZonedDateTime afterNotificationTime = ZonedDateTime.now(ZoneId.systemDefault()).with(LocalTime.of(10, 0));
|
||||
|
||||
assertEquals(
|
||||
afterNotificationTime.with(LocalTime.of(9, 53, 12)).plusDays(1).toInstant(),
|
||||
SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),
|
||||
Clock.fixed(afterNotificationTime.toInstant(), ZoneId.systemDefault())));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue