From 5e8a0b2cfaa4f64121d7a49aba005caadcadb553 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:36:54 -0400 Subject: [PATCH] Introduce a utility class for finding reasonable times to send push notifications --- service/pom.xml | 8 ++ .../scheduler/SchedulingUtil.java | 94 +++++++++++++++++++ .../scheduler/SchedulingUtilTest.java | 68 ++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtil.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtilTest.java diff --git a/service/pom.xml b/service/pom.xml index 7afc44f7b..f2f64b5fc 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -393,6 +393,14 @@ libphonenumber + + + com.googlecode.libphonenumber + geocoder + 2.234 + + net.sourceforge.argparse4j argparse4j diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtil.java new file mode 100644 index 000000000..65b07a0c4 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtil.java @@ -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. + *

+ * 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 getZoneOffset(final Account account, final Clock clock) { + try { + final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(account.getNumber(), null); + + final List timeZonesForNumber = + PhoneNumberToTimeZonesMapper.getInstance().getTimeZonesForNumber(phoneNumber); + + if (timeZonesForNumber.equals(List.of(PhoneNumberToTimeZonesMapper.getUnknownTimeZone()))) { + return Optional.empty(); + } + + final List 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(); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtilTest.java new file mode 100644 index 000000000..13ec7aae9 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtilTest.java @@ -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()))); + } + } +}