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())));
+ }
+ }
+}