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>
|
<artifactId>libphonenumber</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>net.sourceforge.argparse4j</groupId>
|
<groupId>net.sourceforge.argparse4j</groupId>
|
||||||
<artifactId>argparse4j</artifactId>
|
<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