Generalize push notification scheduler and add support for delayed "new messages" notifications
This commit is contained in:
		
							parent
							
								
									5892dc71fa
								
							
						
					
					
						commit
						659ac2c107
					
				|  | @ -188,7 +188,7 @@ import org.whispersystems.textsecuregcm.metrics.TrafficSource; | ||||||
| import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; | import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; | ||||||
| import org.whispersystems.textsecuregcm.providers.RedisClusterHealthCheck; | import org.whispersystems.textsecuregcm.providers.RedisClusterHealthCheck; | ||||||
| import org.whispersystems.textsecuregcm.push.APNSender; | import org.whispersystems.textsecuregcm.push.APNSender; | ||||||
| import org.whispersystems.textsecuregcm.push.ApnPushNotificationScheduler; | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | ||||||
| import org.whispersystems.textsecuregcm.push.FcmSender; | import org.whispersystems.textsecuregcm.push.FcmSender; | ||||||
| import org.whispersystems.textsecuregcm.push.MessageSender; | import org.whispersystems.textsecuregcm.push.MessageSender; | ||||||
|  | @ -649,10 +649,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration | ||||||
|     RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs); |     RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs); | ||||||
|     APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration()); |     APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration()); | ||||||
|     FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials().value()); |     FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials().value()); | ||||||
|     ApnPushNotificationScheduler apnPushNotificationScheduler = new ApnPushNotificationScheduler(pushSchedulerCluster, |     PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster, | ||||||
|         apnSender, accountsManager, 0); |         apnSender, fcmSender, accountsManager, 0, 0); | ||||||
|     PushNotificationManager pushNotificationManager = |     PushNotificationManager pushNotificationManager = | ||||||
|         new PushNotificationManager(accountsManager, apnSender, fcmSender, apnPushNotificationScheduler); |         new PushNotificationManager(accountsManager, apnSender, fcmSender, pushNotificationScheduler); | ||||||
|     RateLimiters rateLimiters = RateLimiters.createAndValidate(config.getLimitsConfiguration(), |     RateLimiters rateLimiters = RateLimiters.createAndValidate(config.getLimitsConfiguration(), | ||||||
|         dynamicConfigurationManager, rateLimitersCluster); |         dynamicConfigurationManager, rateLimitersCluster); | ||||||
|     ProvisioningManager provisioningManager = new ProvisioningManager( |     ProvisioningManager provisioningManager = new ProvisioningManager( | ||||||
|  | @ -743,7 +743,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration | ||||||
|         subscriptionProcessorRetryExecutor); |         subscriptionProcessorRetryExecutor); | ||||||
| 
 | 
 | ||||||
|     environment.lifecycle().manage(apnSender); |     environment.lifecycle().manage(apnSender); | ||||||
|     environment.lifecycle().manage(apnPushNotificationScheduler); |     environment.lifecycle().manage(pushNotificationScheduler); | ||||||
|     environment.lifecycle().manage(provisioningManager); |     environment.lifecycle().manage(provisioningManager); | ||||||
|     environment.lifecycle().manage(messagesCache); |     environment.lifecycle().manage(messagesCache); | ||||||
|     environment.lifecycle().manage(clientPresenceManager); |     environment.lifecycle().manage(clientPresenceManager); | ||||||
|  | @ -1006,7 +1006,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration | ||||||
|     webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator, new AccountPrincipalSupplier(accountsManager))); |     webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator, new AccountPrincipalSupplier(accountsManager))); | ||||||
|     webSocketEnvironment.setConnectListener( |     webSocketEnvironment.setConnectListener( | ||||||
|         new AuthenticatedConnectListener(receiptSender, messagesManager, messageMetrics, pushNotificationManager, |         new AuthenticatedConnectListener(receiptSender, messagesManager, messageMetrics, pushNotificationManager, | ||||||
|             clientPresenceManager, websocketScheduledExecutor, messageDeliveryScheduler, clientReleaseManager)); |             pushNotificationScheduler, clientPresenceManager, websocketScheduledExecutor, messageDeliveryScheduler, | ||||||
|  |             clientReleaseManager)); | ||||||
|     webSocketEnvironment.jersey() |     webSocketEnvironment.jersey() | ||||||
|         .register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); |         .register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); | ||||||
|     webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET)); |     webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET)); | ||||||
|  | @ -1099,7 +1100,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration | ||||||
|         new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()), |         new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()), | ||||||
|         new KeyTransparencyController(keyTransparencyServiceClient), |         new KeyTransparencyController(keyTransparencyServiceClient), | ||||||
|         new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, receiptSender, |         new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, receiptSender, | ||||||
|             accountsManager, messagesManager, pushNotificationManager, reportMessageManager, |             accountsManager, messagesManager, pushNotificationManager, pushNotificationScheduler, reportMessageManager, | ||||||
|             multiRecipientMessageExecutor, messageDeliveryScheduler, reportSpamTokenProvider, clientReleaseManager, |             multiRecipientMessageExecutor, messageDeliveryScheduler, reportSpamTokenProvider, clientReleaseManager, | ||||||
|             dynamicConfigurationManager, zkSecretParams, spamChecker, messageMetrics, Clock.systemUTC()), |             dynamicConfigurationManager, zkSecretParams, spamChecker, messageMetrics, Clock.systemUTC()), | ||||||
|         new PaymentsController(currencyManager, paymentsCredentialsGenerator), |         new PaymentsController(currencyManager, paymentsCredentialsGenerator), | ||||||
|  |  | ||||||
|  | @ -113,6 +113,7 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; | ||||||
| import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; | import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; | ||||||
| import org.whispersystems.textsecuregcm.push.MessageSender; | import org.whispersystems.textsecuregcm.push.MessageSender; | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ReceiptSender; | import org.whispersystems.textsecuregcm.push.ReceiptSender; | ||||||
| import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; | import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; | ||||||
| import org.whispersystems.textsecuregcm.spam.SpamChecker; | import org.whispersystems.textsecuregcm.spam.SpamChecker; | ||||||
|  | @ -157,6 +158,7 @@ public class MessageController { | ||||||
|   private final AccountsManager accountsManager; |   private final AccountsManager accountsManager; | ||||||
|   private final MessagesManager messagesManager; |   private final MessagesManager messagesManager; | ||||||
|   private final PushNotificationManager pushNotificationManager; |   private final PushNotificationManager pushNotificationManager; | ||||||
|  |   private final PushNotificationScheduler pushNotificationScheduler; | ||||||
|   private final ReportMessageManager reportMessageManager; |   private final ReportMessageManager reportMessageManager; | ||||||
|   private final ExecutorService multiRecipientMessageExecutor; |   private final ExecutorService multiRecipientMessageExecutor; | ||||||
|   private final Scheduler messageDeliveryScheduler; |   private final Scheduler messageDeliveryScheduler; | ||||||
|  | @ -208,6 +210,8 @@ public class MessageController { | ||||||
|   @VisibleForTesting |   @VisibleForTesting | ||||||
|   static final long MAX_MESSAGE_SIZE = DataSize.kibibytes(256).toBytes(); |   static final long MAX_MESSAGE_SIZE = DataSize.kibibytes(256).toBytes(); | ||||||
| 
 | 
 | ||||||
|  |   private static final Duration NOTIFY_FOR_REMAINING_MESSAGES_DELAY = Duration.ofMinutes(1); | ||||||
|  | 
 | ||||||
|   public MessageController( |   public MessageController( | ||||||
|       RateLimiters rateLimiters, |       RateLimiters rateLimiters, | ||||||
|       CardinalityEstimator messageByteLimitEstimator, |       CardinalityEstimator messageByteLimitEstimator, | ||||||
|  | @ -216,6 +220,7 @@ public class MessageController { | ||||||
|       AccountsManager accountsManager, |       AccountsManager accountsManager, | ||||||
|       MessagesManager messagesManager, |       MessagesManager messagesManager, | ||||||
|       PushNotificationManager pushNotificationManager, |       PushNotificationManager pushNotificationManager, | ||||||
|  |       PushNotificationScheduler pushNotificationScheduler, | ||||||
|       ReportMessageManager reportMessageManager, |       ReportMessageManager reportMessageManager, | ||||||
|       @Nonnull ExecutorService multiRecipientMessageExecutor, |       @Nonnull ExecutorService multiRecipientMessageExecutor, | ||||||
|       Scheduler messageDeliveryScheduler, |       Scheduler messageDeliveryScheduler, | ||||||
|  | @ -233,6 +238,7 @@ public class MessageController { | ||||||
|     this.accountsManager = accountsManager; |     this.accountsManager = accountsManager; | ||||||
|     this.messagesManager = messagesManager; |     this.messagesManager = messagesManager; | ||||||
|     this.pushNotificationManager = pushNotificationManager; |     this.pushNotificationManager = pushNotificationManager; | ||||||
|  |     this.pushNotificationScheduler = pushNotificationScheduler; | ||||||
|     this.reportMessageManager = reportMessageManager; |     this.reportMessageManager = reportMessageManager; | ||||||
|     this.multiRecipientMessageExecutor = Objects.requireNonNull(multiRecipientMessageExecutor); |     this.multiRecipientMessageExecutor = Objects.requireNonNull(multiRecipientMessageExecutor); | ||||||
|     this.messageDeliveryScheduler = messageDeliveryScheduler; |     this.messageDeliveryScheduler = messageDeliveryScheduler; | ||||||
|  | @ -779,6 +785,10 @@ public class MessageController { | ||||||
|           Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))) |           Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))) | ||||||
|               .record(estimateMessageListSizeBytes(messages)); |               .record(estimateMessageListSizeBytes(messages)); | ||||||
| 
 | 
 | ||||||
|  |           if (messagesAndHasMore.second()) { | ||||||
|  |             pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(), auth.getAuthenticatedDevice(), NOTIFY_FOR_REMAINING_MESSAGES_DELAY); | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|           return messages; |           return messages; | ||||||
|         }) |         }) | ||||||
|         .timeout(Duration.ofSeconds(5)) |         .timeout(Duration.ofSeconds(5)) | ||||||
|  |  | ||||||
|  | @ -1,439 +0,0 @@ | ||||||
| /* |  | ||||||
|  * Copyright 2013-2020 Signal Messenger, LLC |  | ||||||
|  * SPDX-License-Identifier: AGPL-3.0-only |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| package org.whispersystems.textsecuregcm.push; |  | ||||||
| 
 |  | ||||||
| import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; |  | ||||||
| 
 |  | ||||||
| import com.google.common.annotations.VisibleForTesting; |  | ||||||
| import io.dropwizard.lifecycle.Managed; |  | ||||||
| import io.lettuce.core.Limit; |  | ||||||
| import io.lettuce.core.Range; |  | ||||||
| import io.lettuce.core.RedisException; |  | ||||||
| import io.lettuce.core.ScriptOutputType; |  | ||||||
| import io.lettuce.core.SetArgs; |  | ||||||
| import io.lettuce.core.cluster.SlotHash; |  | ||||||
| import io.micrometer.core.instrument.Counter; |  | ||||||
| import io.micrometer.core.instrument.Metrics; |  | ||||||
| import java.io.IOException; |  | ||||||
| import java.time.Clock; |  | ||||||
| import java.time.Duration; |  | ||||||
| import java.time.Instant; |  | ||||||
| import java.util.Collections; |  | ||||||
| import java.util.List; |  | ||||||
| import java.util.Optional; |  | ||||||
| import java.util.UUID; |  | ||||||
| import java.util.concurrent.CompletionStage; |  | ||||||
| import java.util.concurrent.ExecutionException; |  | ||||||
| import java.util.concurrent.TimeUnit; |  | ||||||
| import java.util.concurrent.atomic.AtomicBoolean; |  | ||||||
| import java.util.function.Consumer; |  | ||||||
| import org.apache.commons.lang3.StringUtils; |  | ||||||
| import org.slf4j.Logger; |  | ||||||
| import org.slf4j.LoggerFactory; |  | ||||||
| import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; |  | ||||||
| import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.Account; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.AccountsManager; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.Device; |  | ||||||
| import org.whispersystems.textsecuregcm.util.Pair; |  | ||||||
| import org.whispersystems.textsecuregcm.util.RedisClusterUtil; |  | ||||||
| import org.whispersystems.textsecuregcm.util.Util; |  | ||||||
| 
 |  | ||||||
| public class ApnPushNotificationScheduler implements Managed { |  | ||||||
| 
 |  | ||||||
|   private static final Logger logger = LoggerFactory.getLogger(ApnPushNotificationScheduler.class); |  | ||||||
| 
 |  | ||||||
|   private static final String PENDING_RECURRING_VOIP_NOTIFICATIONS_KEY_PREFIX = "PENDING_APN"; |  | ||||||
|   private static final String PENDING_BACKGROUND_NOTIFICATIONS_KEY_PREFIX = "PENDING_BACKGROUND_APN"; |  | ||||||
|   private static final String LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX = "LAST_BACKGROUND_NOTIFICATION"; |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   static final String NEXT_SLOT_TO_PROCESS_KEY = "pending_notification_next_slot"; |  | ||||||
| 
 |  | ||||||
|   private static final Counter delivered = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_delivered")); |  | ||||||
|   private static final Counter sent = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_sent")); |  | ||||||
|   private static final Counter retry = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_retry")); |  | ||||||
|   private static final Counter evicted = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_evicted")); |  | ||||||
| 
 |  | ||||||
|   private static final Counter backgroundNotificationScheduledCounter = Metrics.counter(name(ApnPushNotificationScheduler.class, "backgroundNotification", "scheduled")); |  | ||||||
|   private static final Counter backgroundNotificationSentCounter = Metrics.counter(name(ApnPushNotificationScheduler.class, "backgroundNotification", "sent")); |  | ||||||
| 
 |  | ||||||
|   private final APNSender apnSender; |  | ||||||
|   private final AccountsManager accountsManager; |  | ||||||
|   private final FaultTolerantRedisCluster pushSchedulingCluster; |  | ||||||
|   private final Clock clock; |  | ||||||
| 
 |  | ||||||
|   private final ClusterLuaScript getPendingVoipDestinationsScript; |  | ||||||
|   private final ClusterLuaScript insertPendingVoipDestinationScript; |  | ||||||
|   private final ClusterLuaScript removePendingVoipDestinationScript; |  | ||||||
| 
 |  | ||||||
|   private final ClusterLuaScript scheduleBackgroundNotificationScript; |  | ||||||
| 
 |  | ||||||
|   private final Thread[] workerThreads; |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   static final Duration BACKGROUND_NOTIFICATION_PERIOD = Duration.ofMinutes(20); |  | ||||||
| 
 |  | ||||||
|   private final AtomicBoolean running = new AtomicBoolean(false); |  | ||||||
| 
 |  | ||||||
|   class NotificationWorker implements Runnable { |  | ||||||
| 
 |  | ||||||
|     private static final int PAGE_SIZE = 128; |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void run() { |  | ||||||
|       do { |  | ||||||
|         try { |  | ||||||
|           final long entriesProcessed = processNextSlot(); |  | ||||||
| 
 |  | ||||||
|           if (entriesProcessed == 0) { |  | ||||||
|             Util.sleep(1000); |  | ||||||
|           } |  | ||||||
|         } catch (Exception e) { |  | ||||||
|           logger.warn("Exception while operating", e); |  | ||||||
|         } |  | ||||||
|       } while (running.get()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private long processNextSlot() { |  | ||||||
|       final int slot = (int) (pushSchedulingCluster.withCluster(connection -> |  | ||||||
|           connection.sync().incr(NEXT_SLOT_TO_PROCESS_KEY)) % SlotHash.SLOT_COUNT); |  | ||||||
| 
 |  | ||||||
|       return processRecurringVoipNotifications(slot) + processScheduledBackgroundNotifications(slot); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @VisibleForTesting |  | ||||||
|     long processRecurringVoipNotifications(final int slot) { |  | ||||||
|       List<String> pendingDestinations; |  | ||||||
|       long entriesProcessed = 0; |  | ||||||
| 
 |  | ||||||
|       do { |  | ||||||
|         pendingDestinations = getPendingDestinationsForRecurringVoipNotifications(slot, PAGE_SIZE); |  | ||||||
|         entriesProcessed += pendingDestinations.size(); |  | ||||||
| 
 |  | ||||||
|         for (final String destination : pendingDestinations) { |  | ||||||
|           try { |  | ||||||
|             getAccountAndDeviceFromPairString(destination).ifPresentOrElse( |  | ||||||
|                 accountAndDevice -> sendRecurringVoipNotification(accountAndDevice.first(), accountAndDevice.second()), |  | ||||||
|                 () -> removeRecurringVoipNotificationEntrySync(destination)); |  | ||||||
|           } catch (final IllegalArgumentException e) { |  | ||||||
|             logger.warn("Failed to parse account/device pair: {}", destination, e); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } while (!pendingDestinations.isEmpty()); |  | ||||||
| 
 |  | ||||||
|       return entriesProcessed; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @VisibleForTesting |  | ||||||
|     long processScheduledBackgroundNotifications(final int slot) { |  | ||||||
|       final long currentTimeMillis = clock.millis(); |  | ||||||
|       final String queueKey = getPendingBackgroundNotificationQueueKey(slot); |  | ||||||
| 
 |  | ||||||
|       final long processedBackgroundNotifications = pushSchedulingCluster.withCluster(connection -> { |  | ||||||
|         List<String> destinations; |  | ||||||
|         long offset = 0; |  | ||||||
| 
 |  | ||||||
|         do { |  | ||||||
|           destinations = connection.sync().zrangebyscore(queueKey, Range.create(0, currentTimeMillis), Limit.create(offset, PAGE_SIZE)); |  | ||||||
| 
 |  | ||||||
|           for (final String destination : destinations) { |  | ||||||
|             try { |  | ||||||
|               getAccountAndDeviceFromPairString(destination).ifPresent(accountAndDevice -> |  | ||||||
|                   sendBackgroundNotification(accountAndDevice.first(), accountAndDevice.second())); |  | ||||||
|             } catch (final IllegalArgumentException e) { |  | ||||||
|               logger.warn("Failed to parse account/device pair: {}", destination, e); |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
| 
 |  | ||||||
|           offset += destinations.size(); |  | ||||||
|         } while (destinations.size() == PAGE_SIZE); |  | ||||||
| 
 |  | ||||||
|         return offset; |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       pushSchedulingCluster.useCluster(connection -> |  | ||||||
|           connection.sync().zremrangebyscore(queueKey, Range.create(0, currentTimeMillis))); |  | ||||||
| 
 |  | ||||||
|       return processedBackgroundNotifications; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   public ApnPushNotificationScheduler(FaultTolerantRedisCluster pushSchedulingCluster, |  | ||||||
|       APNSender apnSender, AccountsManager accountsManager, final int dedicatedProcessWorkerThreadCount) |  | ||||||
|       throws IOException { |  | ||||||
| 
 |  | ||||||
|     this(pushSchedulingCluster, apnSender, accountsManager, Clock.systemUTC(), dedicatedProcessWorkerThreadCount); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   ApnPushNotificationScheduler(FaultTolerantRedisCluster pushSchedulingCluster, |  | ||||||
|       APNSender apnSender, |  | ||||||
|       AccountsManager accountsManager, |  | ||||||
|       Clock clock, |  | ||||||
|       int dedicatedProcessThreadCount) throws IOException { |  | ||||||
| 
 |  | ||||||
|     this.apnSender = apnSender; |  | ||||||
|     this.accountsManager = accountsManager; |  | ||||||
|     this.pushSchedulingCluster = pushSchedulingCluster; |  | ||||||
|     this.clock = clock; |  | ||||||
| 
 |  | ||||||
|     this.getPendingVoipDestinationsScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/get.lua", |  | ||||||
|         ScriptOutputType.MULTI); |  | ||||||
|     this.insertPendingVoipDestinationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/insert.lua", |  | ||||||
|         ScriptOutputType.VALUE); |  | ||||||
|     this.removePendingVoipDestinationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/remove.lua", |  | ||||||
|         ScriptOutputType.INTEGER); |  | ||||||
| 
 |  | ||||||
|     this.scheduleBackgroundNotificationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, |  | ||||||
|         "lua/apn/schedule_background_notification.lua", ScriptOutputType.VALUE); |  | ||||||
| 
 |  | ||||||
|     this.workerThreads = new Thread[dedicatedProcessThreadCount]; |  | ||||||
| 
 |  | ||||||
|     for (int i = 0; i < this.workerThreads.length; i++) { |  | ||||||
|       this.workerThreads[i] = new Thread(new NotificationWorker(), "ApnFallbackManagerWorker-" + i); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Schedule a recurring VOIP notification until {@link this#cancelScheduledNotifications} is called or the device is |  | ||||||
|    * removed |  | ||||||
|    * |  | ||||||
|    * @return A CompletionStage that completes when the recurring notification has successfully been scheduled |  | ||||||
|    */ |  | ||||||
|   public CompletionStage<Void> scheduleRecurringVoipNotification(Account account, Device device) { |  | ||||||
|     sent.increment(); |  | ||||||
|     return insertRecurringVoipNotificationEntry(account, device, clock.millis() + (15 * 1000), (15 * 1000)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Schedule a background notification to be sent some time in the future |  | ||||||
|    * |  | ||||||
|    * @return A CompletionStage that completes when the notification has successfully been scheduled |  | ||||||
|    */ |  | ||||||
|   public CompletionStage<Void> scheduleBackgroundNotification(final Account account, final Device device) { |  | ||||||
|     backgroundNotificationScheduledCounter.increment(); |  | ||||||
| 
 |  | ||||||
|     return scheduleBackgroundNotificationScript.executeAsync( |  | ||||||
|         List.of( |  | ||||||
|             getLastBackgroundNotificationTimestampKey(account, device), |  | ||||||
|             getPendingBackgroundNotificationQueueKey(account, device)), |  | ||||||
|         List.of( |  | ||||||
|             getPairString(account, device), |  | ||||||
|             String.valueOf(clock.millis()), |  | ||||||
|             String.valueOf(BACKGROUND_NOTIFICATION_PERIOD.toMillis()))) |  | ||||||
|         .thenAccept(dropValue()); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Cancel a scheduled recurring VOIP notification |  | ||||||
|    * |  | ||||||
|    * @return A CompletionStage that completes when the scheduled task has been cancelled. |  | ||||||
|    */ |  | ||||||
|   public CompletionStage<Void> cancelScheduledNotifications(Account account, Device device) { |  | ||||||
|     return removeRecurringVoipNotificationEntry(account, device) |  | ||||||
|         .thenCompose(removed -> { |  | ||||||
|           if (removed) { |  | ||||||
|             delivered.increment(); |  | ||||||
|           } |  | ||||||
|           return pushSchedulingCluster.withCluster(connection -> |  | ||||||
|               connection.async().zrem( |  | ||||||
|                   getPendingBackgroundNotificationQueueKey(account, device), |  | ||||||
|                   getPairString(account, device))); |  | ||||||
|         }) |  | ||||||
|         .thenAccept(dropValue()); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Override |  | ||||||
|   public synchronized void start() { |  | ||||||
|     running.set(true); |  | ||||||
| 
 |  | ||||||
|     for (final Thread workerThread : workerThreads) { |  | ||||||
|       workerThread.start(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Override |  | ||||||
|   public synchronized void stop() throws InterruptedException { |  | ||||||
|     running.set(false); |  | ||||||
| 
 |  | ||||||
|     for (final Thread workerThread : workerThreads) { |  | ||||||
|       workerThread.join(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private void sendRecurringVoipNotification(final Account account, final Device device) { |  | ||||||
|     String apnId = device.getVoipApnId(); |  | ||||||
| 
 |  | ||||||
|     if (apnId == null) { |  | ||||||
|       removeRecurringVoipNotificationEntrySync(getEndpointKey(account, device)); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     long deviceLastSeen = device.getLastSeen(); |  | ||||||
|     if (deviceLastSeen < clock.millis() - TimeUnit.DAYS.toMillis(7)) { |  | ||||||
|       evicted.increment(); |  | ||||||
|       removeRecurringVoipNotificationEntrySync(getEndpointKey(account, device)); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     apnSender.sendNotification(new PushNotification(apnId, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device, true)); |  | ||||||
|     retry.increment(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   void sendBackgroundNotification(final Account account, final Device device) { |  | ||||||
|     if (StringUtils.isNotBlank(device.getApnId())) { |  | ||||||
|       // It's okay for the "last notification" timestamp to expire after the "cooldown" period has elapsed; a missing |  | ||||||
|       // timestamp and a timestamp older than the period are functionally equivalent. |  | ||||||
|       pushSchedulingCluster.useCluster(connection -> connection.sync().set( |  | ||||||
|           getLastBackgroundNotificationTimestampKey(account, device), |  | ||||||
|           String.valueOf(clock.millis()), new SetArgs().ex(BACKGROUND_NOTIFICATION_PERIOD))); |  | ||||||
| 
 |  | ||||||
|       apnSender.sendNotification(new PushNotification(device.getApnId(), PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, false)); |  | ||||||
| 
 |  | ||||||
|       backgroundNotificationSentCounter.increment(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   static Optional<Pair<String, Byte>> getSeparated(String encoded) { |  | ||||||
|     try { |  | ||||||
|       if (encoded == null) return Optional.empty(); |  | ||||||
| 
 |  | ||||||
|       String[] parts = encoded.split(":"); |  | ||||||
| 
 |  | ||||||
|       if (parts.length != 2) { |  | ||||||
|         logger.warn("Got strange encoded number: " + encoded); |  | ||||||
|         return Optional.empty(); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       return Optional.of(new Pair<>(parts[0], Byte.parseByte(parts[1]))); |  | ||||||
|     } catch (NumberFormatException e) { |  | ||||||
|       logger.warn("Badly formatted: " + encoded, e); |  | ||||||
|       return Optional.empty(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   static String getPairString(final Account account, final Device device) { |  | ||||||
|     return account.getUuid() + ":" + device.getId(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   Optional<Pair<Account, Device>> getAccountAndDeviceFromPairString(final String endpoint) { |  | ||||||
|     try { |  | ||||||
|       if (StringUtils.isBlank(endpoint)) { |  | ||||||
|         throw new IllegalArgumentException("Endpoint must not be blank"); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       final String[] parts = endpoint.split(":"); |  | ||||||
| 
 |  | ||||||
|       if (parts.length != 2) { |  | ||||||
|         throw new IllegalArgumentException("Could not parse endpoint string: " + endpoint); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       final Optional<Account> maybeAccount = accountsManager.getByAccountIdentifier(UUID.fromString(parts[0])); |  | ||||||
| 
 |  | ||||||
|       return maybeAccount.flatMap(account -> account.getDevice(Byte.parseByte(parts[1]))) |  | ||||||
|           .map(device -> new Pair<>(maybeAccount.get(), device)); |  | ||||||
| 
 |  | ||||||
|     } catch (final NumberFormatException e) { |  | ||||||
|       throw new IllegalArgumentException(e); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private boolean removeRecurringVoipNotificationEntrySync(final String endpoint) { |  | ||||||
|     try { |  | ||||||
|       return removeRecurringVoipNotificationEntry(endpoint).toCompletableFuture().get(); |  | ||||||
|     } catch (ExecutionException e) { |  | ||||||
|       if (e.getCause() instanceof RedisException re) { |  | ||||||
|         throw re; |  | ||||||
|       } |  | ||||||
|       throw new RuntimeException(e); |  | ||||||
|     } catch (InterruptedException e) { |  | ||||||
|       throw new RuntimeException(e); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private CompletionStage<Boolean> removeRecurringVoipNotificationEntry(Account account, Device device) { |  | ||||||
|     return removeRecurringVoipNotificationEntry(getEndpointKey(account, device)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private CompletionStage<Boolean> removeRecurringVoipNotificationEntry(final String endpoint) { |  | ||||||
|     return removePendingVoipDestinationScript.executeAsync( |  | ||||||
|         List.of(getPendingRecurringVoipNotificationQueueKey(endpoint), endpoint), |  | ||||||
|         Collections.emptyList()) |  | ||||||
|         .thenApply(result -> ((long) result) > 0); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @SuppressWarnings("unchecked") |  | ||||||
|   @VisibleForTesting |  | ||||||
|   List<String> getPendingDestinationsForRecurringVoipNotifications(final int slot, final int limit) { |  | ||||||
|     return (List<String>) getPendingVoipDestinationsScript.execute( |  | ||||||
|         List.of(getPendingRecurringVoipNotificationQueueKey(slot)), |  | ||||||
|         List.of(String.valueOf(clock.millis()), String.valueOf(limit))); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private CompletionStage<Void> insertRecurringVoipNotificationEntry(final Account account, final Device device, final long timestamp, final long interval) { |  | ||||||
|     final String endpoint = getEndpointKey(account, device); |  | ||||||
| 
 |  | ||||||
|     return insertPendingVoipDestinationScript.executeAsync( |  | ||||||
|         List.of(getPendingRecurringVoipNotificationQueueKey(endpoint), endpoint), |  | ||||||
|         List.of(String.valueOf(timestamp), |  | ||||||
|             String.valueOf(interval), |  | ||||||
|             account.getUuid().toString(), |  | ||||||
|             String.valueOf(device.getId()))) |  | ||||||
|         .thenAccept(dropValue()); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   static String getEndpointKey(final Account account, final Device device) { |  | ||||||
|     return "apn_device::{" + account.getUuid() + "::" + device.getId() + "}"; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private static String getPendingRecurringVoipNotificationQueueKey(final String endpoint) { |  | ||||||
|     return getPendingRecurringVoipNotificationQueueKey(SlotHash.getSlot(endpoint)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private static String getPendingRecurringVoipNotificationQueueKey(final int slot) { |  | ||||||
|     return PENDING_RECURRING_VOIP_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   static String getPendingBackgroundNotificationQueueKey(final Account account, final Device device) { |  | ||||||
|     return getPendingBackgroundNotificationQueueKey(SlotHash.getSlot(getPairString(account, device))); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private static String getPendingBackgroundNotificationQueueKey(final int slot) { |  | ||||||
|     return PENDING_BACKGROUND_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private static String getLastBackgroundNotificationTimestampKey(final Account account, final Device device) { |  | ||||||
|     return LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX + "::{" + getPairString(account, device) + "}"; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   Optional<Instant> getLastBackgroundNotificationTimestamp(final Account account, final Device device) { |  | ||||||
|     return Optional.ofNullable( |  | ||||||
|         pushSchedulingCluster.withCluster(connection -> |  | ||||||
|             connection.sync().get(getLastBackgroundNotificationTimestampKey(account, device)))) |  | ||||||
|         .map(timestampString -> Instant.ofEpochMilli(Long.parseLong(timestampString))); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @VisibleForTesting |  | ||||||
|   Optional<Instant> getNextScheduledBackgroundNotificationTimestamp(final Account account, final Device device) { |  | ||||||
|     return Optional.ofNullable( |  | ||||||
|             pushSchedulingCluster.withCluster(connection -> |  | ||||||
|                 connection.sync().zscore(getPendingBackgroundNotificationQueueKey(account, device), |  | ||||||
|                     getPairString(account, device)))) |  | ||||||
|         .map(timestamp -> Instant.ofEpochMilli(timestamp.longValue())); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private static <T> Consumer<T> dropValue() { |  | ||||||
|     return ignored -> {}; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  | @ -17,7 +17,6 @@ import java.util.function.BiConsumer; | ||||||
| import org.apache.commons.lang3.StringUtils; | import org.apache.commons.lang3.StringUtils; | ||||||
| import org.slf4j.Logger; | import org.slf4j.Logger; | ||||||
| import org.slf4j.LoggerFactory; | import org.slf4j.LoggerFactory; | ||||||
| import org.whispersystems.textsecuregcm.redis.RedisOperation; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.Account; | import org.whispersystems.textsecuregcm.storage.Account; | ||||||
| import org.whispersystems.textsecuregcm.storage.AccountsManager; | import org.whispersystems.textsecuregcm.storage.AccountsManager; | ||||||
| import org.whispersystems.textsecuregcm.storage.Device; | import org.whispersystems.textsecuregcm.storage.Device; | ||||||
|  | @ -28,7 +27,7 @@ public class PushNotificationManager { | ||||||
|   private final AccountsManager accountsManager; |   private final AccountsManager accountsManager; | ||||||
|   private final APNSender apnSender; |   private final APNSender apnSender; | ||||||
|   private final FcmSender fcmSender; |   private final FcmSender fcmSender; | ||||||
|   private final ApnPushNotificationScheduler apnPushNotificationScheduler; |   private final PushNotificationScheduler pushNotificationScheduler; | ||||||
| 
 | 
 | ||||||
|   private static final String SENT_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "sentPushNotification"); |   private static final String SENT_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "sentPushNotification"); | ||||||
|   private static final String FAILED_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "failedPushNotification"); |   private static final String FAILED_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "failedPushNotification"); | ||||||
|  | @ -39,12 +38,12 @@ public class PushNotificationManager { | ||||||
|   public PushNotificationManager(final AccountsManager accountsManager, |   public PushNotificationManager(final AccountsManager accountsManager, | ||||||
|       final APNSender apnSender, |       final APNSender apnSender, | ||||||
|       final FcmSender fcmSender, |       final FcmSender fcmSender, | ||||||
|       final ApnPushNotificationScheduler apnPushNotificationScheduler) { |       final PushNotificationScheduler pushNotificationScheduler) { | ||||||
| 
 | 
 | ||||||
|     this.accountsManager = accountsManager; |     this.accountsManager = accountsManager; | ||||||
|     this.apnSender = apnSender; |     this.apnSender = apnSender; | ||||||
|     this.fcmSender = fcmSender; |     this.fcmSender = fcmSender; | ||||||
|     this.apnPushNotificationScheduler = apnPushNotificationScheduler; |     this.pushNotificationScheduler = pushNotificationScheduler; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public CompletableFuture<Optional<SendPushNotificationResult>> sendNewMessageNotification(final Account destination, final byte destinationDeviceId, final boolean urgent) throws NotPushRegisteredException { |   public CompletableFuture<Optional<SendPushNotificationResult>> sendNewMessageNotification(final Account destination, final byte destinationDeviceId, final boolean urgent) throws NotPushRegisteredException { | ||||||
|  | @ -82,7 +81,7 @@ public class PushNotificationManager { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public void handleMessagesRetrieved(final Account account, final Device device, final String userAgent) { |   public void handleMessagesRetrieved(final Account account, final Device device, final String userAgent) { | ||||||
|     apnPushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors()); |     pushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @VisibleForTesting |   @VisibleForTesting | ||||||
|  | @ -107,8 +106,8 @@ public class PushNotificationManager { | ||||||
|     if (pushNotification.tokenType() == PushNotification.TokenType.APN && !pushNotification.urgent()) { |     if (pushNotification.tokenType() == PushNotification.TokenType.APN && !pushNotification.urgent()) { | ||||||
|       // APNs imposes a per-device limit on background push notifications; schedule a notification for some time in the |       // APNs imposes a per-device limit on background push notifications; schedule a notification for some time in the | ||||||
|       // future (possibly even now!) rather than sending a notification directly |       // future (possibly even now!) rather than sending a notification directly | ||||||
|       return apnPushNotificationScheduler |       return pushNotificationScheduler | ||||||
|           .scheduleBackgroundNotification(pushNotification.destination(), pushNotification.destinationDevice()) |           .scheduleBackgroundApnsNotification(pushNotification.destination(), pushNotification.destinationDevice()) | ||||||
|           .whenComplete(logErrors()) |           .whenComplete(logErrors()) | ||||||
|           .thenApply(ignored -> Optional.<SendPushNotificationResult>empty()) |           .thenApply(ignored -> Optional.<SendPushNotificationResult>empty()) | ||||||
|           .toCompletableFuture(); |           .toCompletableFuture(); | ||||||
|  | @ -149,7 +148,7 @@ public class PushNotificationManager { | ||||||
|             pushNotification.destination() != null && |             pushNotification.destination() != null && | ||||||
|             pushNotification.destinationDevice() != null) { |             pushNotification.destinationDevice() != null) { | ||||||
| 
 | 
 | ||||||
|           apnPushNotificationScheduler.scheduleRecurringVoipNotification( |           pushNotificationScheduler.scheduleRecurringApnsVoipNotification( | ||||||
|                   pushNotification.destination(), |                   pushNotification.destination(), | ||||||
|                   pushNotification.destinationDevice()) |                   pushNotification.destinationDevice()) | ||||||
|               .whenComplete(logErrors()); |               .whenComplete(logErrors()); | ||||||
|  | @ -185,7 +184,7 @@ public class PushNotificationManager { | ||||||
| 
 | 
 | ||||||
|     if (tokenExpired) { |     if (tokenExpired) { | ||||||
|       if (tokenType == PushNotification.TokenType.APN || tokenType == PushNotification.TokenType.APN_VOIP) { |       if (tokenType == PushNotification.TokenType.APN || tokenType == PushNotification.TokenType.APN_VOIP) { | ||||||
|         apnPushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors()); |         pushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors()); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       clearPushToken(account, device, tokenType); |       clearPushToken(account, device, tokenType); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,545 @@ | ||||||
|  | /* | ||||||
|  |  * Copyright 2013-2020 Signal Messenger, LLC | ||||||
|  |  * SPDX-License-Identifier: AGPL-3.0-only | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | package org.whispersystems.textsecuregcm.push; | ||||||
|  | 
 | ||||||
|  | import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; | ||||||
|  | 
 | ||||||
|  | import com.google.common.annotations.VisibleForTesting; | ||||||
|  | import io.dropwizard.lifecycle.Managed; | ||||||
|  | import io.lettuce.core.Range; | ||||||
|  | import io.lettuce.core.ScriptOutputType; | ||||||
|  | import io.lettuce.core.SetArgs; | ||||||
|  | import io.lettuce.core.cluster.SlotHash; | ||||||
|  | import io.micrometer.core.instrument.Counter; | ||||||
|  | import io.micrometer.core.instrument.Metrics; | ||||||
|  | import java.io.IOException; | ||||||
|  | import java.time.Clock; | ||||||
|  | import java.time.Duration; | ||||||
|  | import java.time.Instant; | ||||||
|  | import java.util.Collections; | ||||||
|  | import java.util.List; | ||||||
|  | import java.util.Optional; | ||||||
|  | import java.util.UUID; | ||||||
|  | import java.util.concurrent.CompletableFuture; | ||||||
|  | import java.util.concurrent.CompletionStage; | ||||||
|  | import java.util.concurrent.TimeUnit; | ||||||
|  | import java.util.concurrent.atomic.AtomicBoolean; | ||||||
|  | import java.util.concurrent.atomic.AtomicLong; | ||||||
|  | import java.util.function.BiFunction; | ||||||
|  | import org.apache.commons.lang3.StringUtils; | ||||||
|  | import org.slf4j.Logger; | ||||||
|  | import org.slf4j.LoggerFactory; | ||||||
|  | import org.whispersystems.textsecuregcm.identity.IdentityType; | ||||||
|  | import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; | ||||||
|  | import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; | ||||||
|  | import org.whispersystems.textsecuregcm.storage.Account; | ||||||
|  | import org.whispersystems.textsecuregcm.storage.AccountsManager; | ||||||
|  | import org.whispersystems.textsecuregcm.storage.Device; | ||||||
|  | import org.whispersystems.textsecuregcm.util.Pair; | ||||||
|  | import org.whispersystems.textsecuregcm.util.RedisClusterUtil; | ||||||
|  | import org.whispersystems.textsecuregcm.util.Util; | ||||||
|  | import reactor.core.publisher.Flux; | ||||||
|  | import reactor.core.publisher.Mono; | ||||||
|  | 
 | ||||||
|  | public class PushNotificationScheduler implements Managed { | ||||||
|  | 
 | ||||||
|  |   private static final Logger logger = LoggerFactory.getLogger(PushNotificationScheduler.class); | ||||||
|  | 
 | ||||||
|  |   private static final String PENDING_RECURRING_VOIP_NOTIFICATIONS_KEY_PREFIX = "PENDING_APN"; | ||||||
|  |   private static final String PENDING_BACKGROUND_NOTIFICATIONS_KEY_PREFIX = "PENDING_BACKGROUND_APN"; | ||||||
|  |   private static final String LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX = "LAST_BACKGROUND_NOTIFICATION"; | ||||||
|  |   private static final String PENDING_DELAYED_NOTIFICATIONS_KEY_PREFIX = "DELAYED"; | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   static final String NEXT_SLOT_TO_PROCESS_KEY = "pending_notification_next_slot"; | ||||||
|  | 
 | ||||||
|  |   private static final Counter delivered = Metrics.counter("chat.ApnPushNotificationScheduler.voip_delivered"); | ||||||
|  |   private static final Counter sent = Metrics.counter("chat.ApnPushNotificationScheduler.voip_sent"); | ||||||
|  |   private static final Counter retry = Metrics.counter("chat.ApnPushNotificationScheduler.voip_retry"); | ||||||
|  |   private static final Counter evicted = Metrics.counter("chat.ApnPushNotificationScheduler.voip_evicted"); | ||||||
|  | 
 | ||||||
|  |   private static final Counter BACKGROUND_NOTIFICATION_SCHEDULED_COUNTER = Metrics.counter(name(PushNotificationScheduler.class, "backgroundNotification", "scheduled")); | ||||||
|  |   private static final String BACKGROUND_NOTIFICATION_SENT_COUNTER_NAME = name(PushNotificationScheduler.class, "backgroundNotification", "sent"); | ||||||
|  | 
 | ||||||
|  |   private static final String DELAYED_NOTIFICATION_SCHEDULED_COUNTER_NAME = name(PushNotificationScheduler.class, "delayedNotificationScheduled"); | ||||||
|  |   private static final String DELAYED_NOTIFICATION_SENT_COUNTER_NAME = name(PushNotificationScheduler.class, "delayedNotificationSent"); | ||||||
|  |   private static final String TOKEN_TYPE_TAG = "tokenType"; | ||||||
|  |   private static final String ACCEPTED_TAG = "accepted"; | ||||||
|  | 
 | ||||||
|  |   private final APNSender apnSender; | ||||||
|  |   private final FcmSender fcmSender; | ||||||
|  |   private final AccountsManager accountsManager; | ||||||
|  |   private final FaultTolerantRedisCluster pushSchedulingCluster; | ||||||
|  |   private final Clock clock; | ||||||
|  | 
 | ||||||
|  |   private final ClusterLuaScript getPendingVoipDestinationsScript; | ||||||
|  |   private final ClusterLuaScript insertPendingVoipDestinationScript; | ||||||
|  |   private final ClusterLuaScript removePendingVoipDestinationScript; | ||||||
|  | 
 | ||||||
|  |   private final ClusterLuaScript scheduleBackgroundApnsNotificationScript; | ||||||
|  | 
 | ||||||
|  |   private final Thread[] workerThreads; | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   static final Duration BACKGROUND_NOTIFICATION_PERIOD = Duration.ofMinutes(20); | ||||||
|  | 
 | ||||||
|  |   private final AtomicBoolean running = new AtomicBoolean(false); | ||||||
|  | 
 | ||||||
|  |   class NotificationWorker implements Runnable { | ||||||
|  | 
 | ||||||
|  |     private final int maxConcurrency; | ||||||
|  | 
 | ||||||
|  |     private static final int PAGE_SIZE = 128; | ||||||
|  | 
 | ||||||
|  |     NotificationWorker(final int maxConcurrency) { | ||||||
|  |       this.maxConcurrency = maxConcurrency; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Override | ||||||
|  |     public void run() { | ||||||
|  |       do { | ||||||
|  |         try { | ||||||
|  |           final long entriesProcessed = processNextSlot(); | ||||||
|  | 
 | ||||||
|  |           if (entriesProcessed == 0) { | ||||||
|  |             Util.sleep(1000); | ||||||
|  |           } | ||||||
|  |         } catch (Exception e) { | ||||||
|  |           logger.warn("Exception while operating", e); | ||||||
|  |         } | ||||||
|  |       } while (running.get()); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private long processNextSlot() { | ||||||
|  |       final int slot = (int) (pushSchedulingCluster.withCluster(connection -> | ||||||
|  |           connection.sync().incr(NEXT_SLOT_TO_PROCESS_KEY)) % SlotHash.SLOT_COUNT); | ||||||
|  | 
 | ||||||
|  |       return processRecurringApnsVoipNotifications(slot) + | ||||||
|  |           processScheduledBackgroundApnsNotifications(slot) + | ||||||
|  |           processScheduledDelayedNotifications(slot); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     long processRecurringApnsVoipNotifications(final int slot) { | ||||||
|  |       List<String> pendingDestinations; | ||||||
|  |       long entriesProcessed = 0; | ||||||
|  | 
 | ||||||
|  |       do { | ||||||
|  |         pendingDestinations = getPendingDestinationsForRecurringApnsVoipNotifications(slot, PAGE_SIZE); | ||||||
|  |         entriesProcessed += pendingDestinations.size(); | ||||||
|  | 
 | ||||||
|  |         Flux.fromIterable(pendingDestinations) | ||||||
|  |             .flatMap(destination -> Mono.fromFuture(() -> getAccountAndDeviceFromPairString(destination)) | ||||||
|  |                 .flatMap(maybeAccountAndDevice -> { | ||||||
|  |                   if (maybeAccountAndDevice.isPresent()) { | ||||||
|  |                     final Pair<Account, Device> accountAndDevice = maybeAccountAndDevice.get(); | ||||||
|  |                     return Mono.fromFuture(() -> sendRecurringApnsVoipNotification(accountAndDevice.first(), accountAndDevice.second())); | ||||||
|  |                   } else { | ||||||
|  |                     final Pair<UUID, Byte> aciAndDeviceId = decodeAciAndDeviceId(destination); | ||||||
|  |                     return Mono.fromFuture(() -> removeRecurringApnsVoipNotificationEntry(aciAndDeviceId.first(), aciAndDeviceId.second())) | ||||||
|  |                         .then(); | ||||||
|  |                   } | ||||||
|  |                 }), maxConcurrency) | ||||||
|  |             .then() | ||||||
|  |             .block(); | ||||||
|  |       } while (!pendingDestinations.isEmpty()); | ||||||
|  | 
 | ||||||
|  |       return entriesProcessed; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     long processScheduledBackgroundApnsNotifications(final int slot) { | ||||||
|  |       return processScheduledNotifications(getPendingBackgroundApnsNotificationQueueKey(slot), | ||||||
|  |           PushNotificationScheduler.this::sendBackgroundApnsNotification); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     long processScheduledDelayedNotifications(final int slot) { | ||||||
|  |       return processScheduledNotifications(getDelayedNotificationQueueKey(slot), | ||||||
|  |           PushNotificationScheduler.this::sendDelayedNotification); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private long processScheduledNotifications(final String queueKey, | ||||||
|  |         final BiFunction<Account, Device, CompletableFuture<Void>> sendNotificationFunction) { | ||||||
|  | 
 | ||||||
|  |       final long currentTimeMillis = clock.millis(); | ||||||
|  |       final AtomicLong processedNotifications = new AtomicLong(0); | ||||||
|  | 
 | ||||||
|  |       pushSchedulingCluster.useCluster( | ||||||
|  |           connection -> connection.reactive().zrangebyscore(queueKey, Range.create(0, currentTimeMillis)) | ||||||
|  |               .flatMap(encodedAciAndDeviceId -> Mono.fromFuture( | ||||||
|  |                   () -> getAccountAndDeviceFromPairString(encodedAciAndDeviceId)), maxConcurrency) | ||||||
|  |               .flatMap(Mono::justOrEmpty) | ||||||
|  |               .flatMap(accountAndDevice -> Mono.fromFuture( | ||||||
|  |                           () -> sendNotificationFunction.apply(accountAndDevice.first(), accountAndDevice.second())) | ||||||
|  |                       .then(Mono.defer(() -> connection.reactive().zrem(queueKey, encodeAciAndDeviceId(accountAndDevice.first(), accountAndDevice.second())))) | ||||||
|  |                       .doOnSuccess(ignored -> processedNotifications.incrementAndGet()), | ||||||
|  |                   maxConcurrency) | ||||||
|  |               .then() | ||||||
|  |               .block()); | ||||||
|  | 
 | ||||||
|  |       return processedNotifications.get(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public PushNotificationScheduler(final FaultTolerantRedisCluster pushSchedulingCluster, | ||||||
|  |       final APNSender apnSender, | ||||||
|  |       final FcmSender fcmSender, | ||||||
|  |       final AccountsManager accountsManager, | ||||||
|  |       final int dedicatedProcessWorkerThreadCount, | ||||||
|  |       final int workerMaxConcurrency) throws IOException { | ||||||
|  | 
 | ||||||
|  |     this(pushSchedulingCluster, | ||||||
|  |         apnSender, | ||||||
|  |         fcmSender, | ||||||
|  |         accountsManager, | ||||||
|  |         Clock.systemUTC(), | ||||||
|  |         dedicatedProcessWorkerThreadCount, | ||||||
|  |         workerMaxConcurrency); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   PushNotificationScheduler(final FaultTolerantRedisCluster pushSchedulingCluster, | ||||||
|  |                             final APNSender apnSender, | ||||||
|  |                             final FcmSender fcmSender, | ||||||
|  |                             final AccountsManager accountsManager, | ||||||
|  |                             final Clock clock, | ||||||
|  |                             final int dedicatedProcessThreadCount, | ||||||
|  |                             final int workerMaxConcurrency) throws IOException { | ||||||
|  | 
 | ||||||
|  |     this.apnSender = apnSender; | ||||||
|  |     this.fcmSender = fcmSender; | ||||||
|  |     this.accountsManager = accountsManager; | ||||||
|  |     this.pushSchedulingCluster = pushSchedulingCluster; | ||||||
|  |     this.clock = clock; | ||||||
|  | 
 | ||||||
|  |     this.getPendingVoipDestinationsScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/get.lua", | ||||||
|  |         ScriptOutputType.MULTI); | ||||||
|  |     this.insertPendingVoipDestinationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/insert.lua", | ||||||
|  |         ScriptOutputType.VALUE); | ||||||
|  |     this.removePendingVoipDestinationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/remove.lua", | ||||||
|  |         ScriptOutputType.INTEGER); | ||||||
|  | 
 | ||||||
|  |     this.scheduleBackgroundApnsNotificationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, | ||||||
|  |         "lua/apn/schedule_background_notification.lua", ScriptOutputType.VALUE); | ||||||
|  | 
 | ||||||
|  |     this.workerThreads = new Thread[dedicatedProcessThreadCount]; | ||||||
|  | 
 | ||||||
|  |     for (int i = 0; i < this.workerThreads.length; i++) { | ||||||
|  |       this.workerThreads[i] = new Thread(new NotificationWorker(workerMaxConcurrency), "PushNotificationScheduler-" + i); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Schedule a recurring VOIP notification until {@link this#cancelScheduledNotifications} is called or the device is | ||||||
|  |    * removed | ||||||
|  |    * | ||||||
|  |    * @return A CompletionStage that completes when the recurring notification has successfully been scheduled | ||||||
|  |    */ | ||||||
|  |   public CompletionStage<Void> scheduleRecurringApnsVoipNotification(Account account, Device device) { | ||||||
|  |     sent.increment(); | ||||||
|  |     return insertRecurringApnsVoipNotificationEntry(account.getIdentifier(IdentityType.ACI), device.getId(), clock.millis() + (15 * 1000), (15 * 1000)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Schedule a background APNs notification to be sent some time in the future. | ||||||
|  |    * | ||||||
|  |    * @return A CompletionStage that completes when the notification has successfully been scheduled | ||||||
|  |    * | ||||||
|  |    * @throws IllegalArgumentException if the given device does not have an APNs token | ||||||
|  |    */ | ||||||
|  |   public CompletionStage<Void> scheduleBackgroundApnsNotification(final Account account, final Device device) { | ||||||
|  |     if (StringUtils.isBlank(device.getApnId())) { | ||||||
|  |       throw new IllegalArgumentException("Device must have an APNs token"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     BACKGROUND_NOTIFICATION_SCHEDULED_COUNTER.increment(); | ||||||
|  | 
 | ||||||
|  |     return scheduleBackgroundApnsNotificationScript.executeAsync( | ||||||
|  |         List.of( | ||||||
|  |             getLastBackgroundApnsNotificationTimestampKey(account, device), | ||||||
|  |             getPendingBackgroundApnsNotificationQueueKey(account, device)), | ||||||
|  |         List.of( | ||||||
|  |             encodeAciAndDeviceId(account, device), | ||||||
|  |             String.valueOf(clock.millis()), | ||||||
|  |             String.valueOf(BACKGROUND_NOTIFICATION_PERIOD.toMillis()))) | ||||||
|  |         .thenRun(Util.NOOP); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Schedules a "new message" push notification to be delivered to the given device after at least the given duration. | ||||||
|  |    * If another notification had previously been scheduled, calling this method will replace the previously-scheduled | ||||||
|  |    * delivery time with the given time. | ||||||
|  |    * | ||||||
|  |    * @param account the account to which the target device belongs | ||||||
|  |    * @param device the device to which to deliver a "new message" push notification | ||||||
|  |    * @param minDelay the minimum delay after which to deliver the notification | ||||||
|  |    * | ||||||
|  |    * @return a future that completes once the notification has been scheduled | ||||||
|  |    */ | ||||||
|  |   public CompletableFuture<Void> scheduleDelayedNotification(final Account account, final Device device, final Duration minDelay) { | ||||||
|  |     return pushSchedulingCluster.withCluster(connection -> | ||||||
|  |         connection.async().zadd(getDelayedNotificationQueueKey(account, device), | ||||||
|  |             clock.instant().plus(minDelay).toEpochMilli(), | ||||||
|  |             encodeAciAndDeviceId(account, device))) | ||||||
|  |         .thenRun(() -> Metrics.counter(DELAYED_NOTIFICATION_SCHEDULED_COUNTER_NAME, | ||||||
|  |                 TOKEN_TYPE_TAG, getTokenType(device)) | ||||||
|  |             .increment()) | ||||||
|  |         .toCompletableFuture(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Cancel a scheduled recurring VOIP notification | ||||||
|  |    * | ||||||
|  |    * @return A CompletionStage that completes when the scheduled task has been cancelled. | ||||||
|  |    */ | ||||||
|  |   public CompletionStage<Void> cancelScheduledNotifications(Account account, Device device) { | ||||||
|  |     return CompletableFuture.allOf( | ||||||
|  |         cancelRecurringApnsVoipNotifications(account, device), | ||||||
|  |         cancelBackgroundApnsNotifications(account, device), | ||||||
|  |         cancelDelayedNotifications(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private CompletableFuture<Void> cancelRecurringApnsVoipNotifications(final Account account, final Device device) { | ||||||
|  |     return removeRecurringApnsVoipNotificationEntry(account.getIdentifier(IdentityType.ACI), device.getId()) | ||||||
|  |         .thenCompose(removed -> { | ||||||
|  |           if (removed) { | ||||||
|  |             delivered.increment(); | ||||||
|  |           } | ||||||
|  |           return pushSchedulingCluster.withCluster(connection -> | ||||||
|  |               connection.async().zrem( | ||||||
|  |                   getPendingBackgroundApnsNotificationQueueKey(account, device), | ||||||
|  |                   encodeAciAndDeviceId(account, device))); | ||||||
|  |         }) | ||||||
|  |         .thenRun(Util.NOOP) | ||||||
|  |         .toCompletableFuture(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   CompletableFuture<Void> cancelBackgroundApnsNotifications(final Account account, final Device device) { | ||||||
|  |     return pushSchedulingCluster.withCluster(connection -> connection.async() | ||||||
|  |             .zrem(getPendingBackgroundApnsNotificationQueueKey(account, device), encodeAciAndDeviceId(account, device))) | ||||||
|  |         .thenRun(Util.NOOP) | ||||||
|  |         .toCompletableFuture(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   CompletableFuture<Void> cancelDelayedNotifications(final Account account, final Device device) { | ||||||
|  |     return pushSchedulingCluster.withCluster(connection -> | ||||||
|  |             connection.async().zrem(getDelayedNotificationQueueKey(account, device), | ||||||
|  |                 encodeAciAndDeviceId(account, device))) | ||||||
|  |         .thenRun(Util.NOOP) | ||||||
|  |         .toCompletableFuture(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Override | ||||||
|  |   public synchronized void start() { | ||||||
|  |     running.set(true); | ||||||
|  | 
 | ||||||
|  |     for (final Thread workerThread : workerThreads) { | ||||||
|  |       workerThread.start(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Override | ||||||
|  |   public synchronized void stop() throws InterruptedException { | ||||||
|  |     running.set(false); | ||||||
|  | 
 | ||||||
|  |     for (final Thread workerThread : workerThreads) { | ||||||
|  |       workerThread.join(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private CompletableFuture<Void> sendRecurringApnsVoipNotification(final Account account, final Device device) { | ||||||
|  |     if (StringUtils.isBlank(device.getVoipApnId())) { | ||||||
|  |       return removeRecurringApnsVoipNotificationEntry(account.getIdentifier(IdentityType.ACI), device.getId()) | ||||||
|  |           .thenRun(Util.NOOP); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (device.getLastSeen() < clock.millis() - TimeUnit.DAYS.toMillis(7)) { | ||||||
|  |       return removeRecurringApnsVoipNotificationEntry(account.getIdentifier(IdentityType.ACI), device.getId()) | ||||||
|  |           .thenRun(evicted::increment); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return apnSender.sendNotification(new PushNotification(device.getVoipApnId(), PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device, true)) | ||||||
|  |         .thenRun(retry::increment); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   CompletableFuture<Void> sendBackgroundApnsNotification(final Account account, final Device device) { | ||||||
|  |     if (StringUtils.isBlank(device.getApnId())) { | ||||||
|  |       return CompletableFuture.completedFuture(null); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // It's okay for the "last notification" timestamp to expire after the "cooldown" period has elapsed; a missing | ||||||
|  |     // timestamp and a timestamp older than the period are functionally equivalent. | ||||||
|  |     return pushSchedulingCluster.withCluster(connection -> connection.async().set( | ||||||
|  |         getLastBackgroundApnsNotificationTimestampKey(account, device), | ||||||
|  |         String.valueOf(clock.millis()), new SetArgs().ex(BACKGROUND_NOTIFICATION_PERIOD))) | ||||||
|  |         .thenCompose(ignored -> apnSender.sendNotification(new PushNotification(device.getApnId(), PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, false))) | ||||||
|  |         .thenAccept(response -> Metrics.counter(BACKGROUND_NOTIFICATION_SENT_COUNTER_NAME, | ||||||
|  |                 ACCEPTED_TAG, String.valueOf(response.accepted())) | ||||||
|  |             .increment()) | ||||||
|  |         .toCompletableFuture(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   CompletableFuture<Void> sendDelayedNotification(final Account account, final Device device) { | ||||||
|  |     if (StringUtils.isAllBlank(device.getApnId(), device.getGcmId())) { | ||||||
|  |       return CompletableFuture.completedFuture(null); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final boolean isApnsDevice = StringUtils.isNotBlank(device.getApnId()); | ||||||
|  | 
 | ||||||
|  |     final PushNotification pushNotification = new PushNotification( | ||||||
|  |         isApnsDevice ? device.getApnId() : device.getGcmId(), | ||||||
|  |         isApnsDevice ? PushNotification.TokenType.APN : PushNotification.TokenType.FCM, | ||||||
|  |         PushNotification.NotificationType.NOTIFICATION, | ||||||
|  |         null, | ||||||
|  |         account, | ||||||
|  |         device, | ||||||
|  |         true); | ||||||
|  | 
 | ||||||
|  |     final PushNotificationSender pushNotificationSender = isApnsDevice ? apnSender : fcmSender; | ||||||
|  | 
 | ||||||
|  |     return pushNotificationSender.sendNotification(pushNotification) | ||||||
|  |         .thenAccept(response -> Metrics.counter(DELAYED_NOTIFICATION_SENT_COUNTER_NAME, | ||||||
|  |             TOKEN_TYPE_TAG, getTokenType(device), | ||||||
|  |             ACCEPTED_TAG, String.valueOf(response.accepted())) | ||||||
|  |             .increment()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   static String encodeAciAndDeviceId(final Account account, final Device device) { | ||||||
|  |     return account.getUuid() + ":" + device.getId(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Pair<UUID, Byte> decodeAciAndDeviceId(final String encoded) { | ||||||
|  |     if (StringUtils.isBlank(encoded)) { | ||||||
|  |       throw new IllegalArgumentException("Encoded ACI/device ID pair must not be blank"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final int separatorIndex = encoded.indexOf(':'); | ||||||
|  | 
 | ||||||
|  |     if (separatorIndex == -1) { | ||||||
|  |       throw new IllegalArgumentException("String did not contain a ':' separator"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final UUID aci = UUID.fromString(encoded.substring(0, separatorIndex)); | ||||||
|  |     final byte deviceId = Byte.parseByte(encoded.substring(separatorIndex + 1)); | ||||||
|  | 
 | ||||||
|  |     return new Pair<>(aci, deviceId); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   CompletableFuture<Optional<Pair<Account, Device>>> getAccountAndDeviceFromPairString(final String endpoint) { | ||||||
|  |     final Pair<UUID, Byte> aciAndDeviceId = decodeAciAndDeviceId(endpoint); | ||||||
|  | 
 | ||||||
|  |     return accountsManager.getByAccountIdentifierAsync(aciAndDeviceId.first()) | ||||||
|  |         .thenApply(maybeAccount -> maybeAccount | ||||||
|  |             .flatMap(account -> account.getDevice(aciAndDeviceId.second()).map(device -> new Pair<>(account, device)))); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private CompletableFuture<Boolean> removeRecurringApnsVoipNotificationEntry(final UUID aci, final byte deviceId) { | ||||||
|  |     final String endpoint = getVoipEndpointKey(aci, deviceId); | ||||||
|  | 
 | ||||||
|  |     return removePendingVoipDestinationScript.executeAsync( | ||||||
|  |         List.of(getPendingRecurringApnsVoipNotificationQueueKey(endpoint), endpoint), Collections.emptyList()) | ||||||
|  |         .thenApply(result -> ((long) result) > 0); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @SuppressWarnings("unchecked") | ||||||
|  |   @VisibleForTesting | ||||||
|  |   List<String> getPendingDestinationsForRecurringApnsVoipNotifications(final int slot, final int limit) { | ||||||
|  |     return (List<String>) getPendingVoipDestinationsScript.execute( | ||||||
|  |         List.of(getPendingRecurringApnsVoipNotificationQueueKey(slot)), | ||||||
|  |         List.of(String.valueOf(clock.millis()), String.valueOf(limit))); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @SuppressWarnings("SameParameterValue") | ||||||
|  |   private CompletionStage<Void> insertRecurringApnsVoipNotificationEntry(final UUID aci, final byte deviceId, final long timestamp, final long interval) { | ||||||
|  |     final String endpoint = getVoipEndpointKey(aci, deviceId); | ||||||
|  | 
 | ||||||
|  |     return insertPendingVoipDestinationScript.executeAsync( | ||||||
|  |         List.of(getPendingRecurringApnsVoipNotificationQueueKey(endpoint), endpoint), | ||||||
|  |         List.of(String.valueOf(timestamp), | ||||||
|  |             String.valueOf(interval), | ||||||
|  |             aci.toString(), | ||||||
|  |             String.valueOf(deviceId))) | ||||||
|  |         .thenRun(Util.NOOP); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   static String getVoipEndpointKey(final UUID aci, final byte deviceId) { | ||||||
|  |     return "apn_device::{" + aci + "::" + deviceId + "}"; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static String getPendingRecurringApnsVoipNotificationQueueKey(final String endpoint) { | ||||||
|  |     return getPendingRecurringApnsVoipNotificationQueueKey(SlotHash.getSlot(endpoint)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static String getPendingRecurringApnsVoipNotificationQueueKey(final int slot) { | ||||||
|  |     return PENDING_RECURRING_VOIP_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   static String getPendingBackgroundApnsNotificationQueueKey(final Account account, final Device device) { | ||||||
|  |     return getPendingBackgroundApnsNotificationQueueKey(SlotHash.getSlot(encodeAciAndDeviceId(account, device))); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static String getPendingBackgroundApnsNotificationQueueKey(final int slot) { | ||||||
|  |     return PENDING_BACKGROUND_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static String getLastBackgroundApnsNotificationTimestampKey(final Account account, final Device device) { | ||||||
|  |     return LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX + "::{" + encodeAciAndDeviceId(account, device) + "}"; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   static String getDelayedNotificationQueueKey(final Account account, final Device device) { | ||||||
|  |     return getDelayedNotificationQueueKey(SlotHash.getSlot(encodeAciAndDeviceId(account, device))); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static String getDelayedNotificationQueueKey(final int slot) { | ||||||
|  |     return PENDING_DELAYED_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   Optional<Instant> getLastBackgroundApnsNotificationTimestamp(final Account account, final Device device) { | ||||||
|  |     return Optional.ofNullable( | ||||||
|  |         pushSchedulingCluster.withCluster(connection -> | ||||||
|  |             connection.sync().get(getLastBackgroundApnsNotificationTimestampKey(account, device)))) | ||||||
|  |         .map(timestampString -> Instant.ofEpochMilli(Long.parseLong(timestampString))); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   Optional<Instant> getNextScheduledBackgroundApnsNotificationTimestamp(final Account account, final Device device) { | ||||||
|  |     return Optional.ofNullable( | ||||||
|  |             pushSchedulingCluster.withCluster(connection -> | ||||||
|  |                 connection.sync().zscore(getPendingBackgroundApnsNotificationQueueKey(account, device), | ||||||
|  |                     encodeAciAndDeviceId(account, device)))) | ||||||
|  |         .map(timestamp -> Instant.ofEpochMilli(timestamp.longValue())); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @VisibleForTesting | ||||||
|  |   Optional<Instant> getNextScheduledDelayedNotificationTimestamp(final Account account, final Device device) { | ||||||
|  |     return Optional.ofNullable( | ||||||
|  |             pushSchedulingCluster.withCluster(connection -> | ||||||
|  |                 connection.sync().zscore(getDelayedNotificationQueueKey(account, device), | ||||||
|  |                     encodeAciAndDeviceId(account, device)))) | ||||||
|  |         .map(timestamp -> Instant.ofEpochMilli(timestamp.longValue())); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private static String getTokenType(final Device device) { | ||||||
|  |     if (StringUtils.isNotBlank(device.getApnId())) { | ||||||
|  |       return "apns"; | ||||||
|  |     } else if (StringUtils.isNotBlank(device.getGcmId())) { | ||||||
|  |       return "fcm"; | ||||||
|  |     } else { | ||||||
|  |       return "unknown"; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -24,6 +24,7 @@ import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | ||||||
| import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; | import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; | ||||||
| import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ReceiptSender; | import org.whispersystems.textsecuregcm.push.ReceiptSender; | ||||||
| import org.whispersystems.textsecuregcm.redis.RedisOperation; | import org.whispersystems.textsecuregcm.redis.RedisOperation; | ||||||
| import org.whispersystems.textsecuregcm.storage.ClientReleaseManager; | import org.whispersystems.textsecuregcm.storage.ClientReleaseManager; | ||||||
|  | @ -52,6 +53,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener { | ||||||
|   private final MessagesManager messagesManager; |   private final MessagesManager messagesManager; | ||||||
|   private final MessageMetrics messageMetrics; |   private final MessageMetrics messageMetrics; | ||||||
|   private final PushNotificationManager pushNotificationManager; |   private final PushNotificationManager pushNotificationManager; | ||||||
|  |   private final PushNotificationScheduler pushNotificationScheduler; | ||||||
|   private final ClientPresenceManager clientPresenceManager; |   private final ClientPresenceManager clientPresenceManager; | ||||||
|   private final ScheduledExecutorService scheduledExecutorService; |   private final ScheduledExecutorService scheduledExecutorService; | ||||||
|   private final Scheduler messageDeliveryScheduler; |   private final Scheduler messageDeliveryScheduler; | ||||||
|  | @ -71,6 +73,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener { | ||||||
|       MessagesManager messagesManager, |       MessagesManager messagesManager, | ||||||
|       MessageMetrics messageMetrics, |       MessageMetrics messageMetrics, | ||||||
|       PushNotificationManager pushNotificationManager, |       PushNotificationManager pushNotificationManager, | ||||||
|  |       PushNotificationScheduler pushNotificationScheduler, | ||||||
|       ClientPresenceManager clientPresenceManager, |       ClientPresenceManager clientPresenceManager, | ||||||
|       ScheduledExecutorService scheduledExecutorService, |       ScheduledExecutorService scheduledExecutorService, | ||||||
|       Scheduler messageDeliveryScheduler, |       Scheduler messageDeliveryScheduler, | ||||||
|  | @ -79,6 +82,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener { | ||||||
|     this.messagesManager = messagesManager; |     this.messagesManager = messagesManager; | ||||||
|     this.messageMetrics = messageMetrics; |     this.messageMetrics = messageMetrics; | ||||||
|     this.pushNotificationManager = pushNotificationManager; |     this.pushNotificationManager = pushNotificationManager; | ||||||
|  |     this.pushNotificationScheduler = pushNotificationScheduler; | ||||||
|     this.clientPresenceManager = clientPresenceManager; |     this.clientPresenceManager = clientPresenceManager; | ||||||
|     this.scheduledExecutorService = scheduledExecutorService; |     this.scheduledExecutorService = scheduledExecutorService; | ||||||
|     this.messageDeliveryScheduler = messageDeliveryScheduler; |     this.messageDeliveryScheduler = messageDeliveryScheduler; | ||||||
|  | @ -142,6 +146,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener { | ||||||
|           messagesManager, |           messagesManager, | ||||||
|           messageMetrics, |           messageMetrics, | ||||||
|           pushNotificationManager, |           pushNotificationManager, | ||||||
|  |           pushNotificationScheduler, | ||||||
|           auth, |           auth, | ||||||
|           context.getClient(), |           context.getClient(), | ||||||
|           scheduledExecutorService, |           scheduledExecutorService, | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ import io.micrometer.core.instrument.DistributionSummary; | ||||||
| import io.micrometer.core.instrument.Metrics; | import io.micrometer.core.instrument.Metrics; | ||||||
| import io.micrometer.core.instrument.Tag; | import io.micrometer.core.instrument.Tag; | ||||||
| import io.micrometer.core.instrument.Tags; | import io.micrometer.core.instrument.Tags; | ||||||
|  | import java.time.Duration; | ||||||
| import java.util.ArrayList; | import java.util.ArrayList; | ||||||
| import java.util.Collections; | import java.util.Collections; | ||||||
| import java.util.List; | import java.util.List; | ||||||
|  | @ -43,8 +44,8 @@ import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | ||||||
| import org.whispersystems.textsecuregcm.metrics.MetricsUtil; | import org.whispersystems.textsecuregcm.metrics.MetricsUtil; | ||||||
| import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; | import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; | ||||||
| import org.whispersystems.textsecuregcm.push.DisplacedPresenceListener; | import org.whispersystems.textsecuregcm.push.DisplacedPresenceListener; | ||||||
| import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; |  | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ReceiptSender; | import org.whispersystems.textsecuregcm.push.ReceiptSender; | ||||||
| import org.whispersystems.textsecuregcm.storage.ClientReleaseManager; | import org.whispersystems.textsecuregcm.storage.ClientReleaseManager; | ||||||
| import org.whispersystems.textsecuregcm.storage.Device; | import org.whispersystems.textsecuregcm.storage.Device; | ||||||
|  | @ -88,8 +89,6 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
|       "sendMessages"); |       "sendMessages"); | ||||||
|   private static final String SEND_MESSAGE_ERROR_COUNTER = MetricsUtil.name(WebSocketConnection.class, |   private static final String SEND_MESSAGE_ERROR_COUNTER = MetricsUtil.name(WebSocketConnection.class, | ||||||
|       "sendMessageError"); |       "sendMessageError"); | ||||||
|   private static final String PUSH_NOTIFICATION_ON_CLOSE_COUNTER_NAME = |  | ||||||
|       MetricsUtil.name(WebSocketConnection.class, "pushNotificationOnClose"); |  | ||||||
|   private static final String STATUS_CODE_TAG = "status"; |   private static final String STATUS_CODE_TAG = "status"; | ||||||
|   private static final String STATUS_MESSAGE_TAG = "message"; |   private static final String STATUS_MESSAGE_TAG = "message"; | ||||||
|   private static final String ERROR_TYPE_TAG = "errorType"; |   private static final String ERROR_TYPE_TAG = "errorType"; | ||||||
|  | @ -109,12 +108,15 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
| 
 | 
 | ||||||
|   private static final int DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS = 5 * 60 * 1000; |   private static final int DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS = 5 * 60 * 1000; | ||||||
| 
 | 
 | ||||||
|  |   private static final Duration CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY = Duration.ofMinutes(1); | ||||||
|  | 
 | ||||||
|   private static final Logger logger = LoggerFactory.getLogger(WebSocketConnection.class); |   private static final Logger logger = LoggerFactory.getLogger(WebSocketConnection.class); | ||||||
| 
 | 
 | ||||||
|   private final ReceiptSender receiptSender; |   private final ReceiptSender receiptSender; | ||||||
|   private final MessagesManager messagesManager; |   private final MessagesManager messagesManager; | ||||||
|   private final MessageMetrics messageMetrics; |   private final MessageMetrics messageMetrics; | ||||||
|   private final PushNotificationManager pushNotificationManager; |   private final PushNotificationManager pushNotificationManager; | ||||||
|  |   private final PushNotificationScheduler pushNotificationScheduler; | ||||||
| 
 | 
 | ||||||
|   private final AuthenticatedDevice auth; |   private final AuthenticatedDevice auth; | ||||||
|   private final WebSocketClient client; |   private final WebSocketClient client; | ||||||
|  | @ -148,6 +150,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
|       MessagesManager messagesManager, |       MessagesManager messagesManager, | ||||||
|       MessageMetrics messageMetrics, |       MessageMetrics messageMetrics, | ||||||
|       PushNotificationManager pushNotificationManager, |       PushNotificationManager pushNotificationManager, | ||||||
|  |       PushNotificationScheduler pushNotificationScheduler, | ||||||
|       AuthenticatedDevice auth, |       AuthenticatedDevice auth, | ||||||
|       WebSocketClient client, |       WebSocketClient client, | ||||||
|       ScheduledExecutorService scheduledExecutorService, |       ScheduledExecutorService scheduledExecutorService, | ||||||
|  | @ -158,6 +161,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
|         messagesManager, |         messagesManager, | ||||||
|         messageMetrics, |         messageMetrics, | ||||||
|         pushNotificationManager, |         pushNotificationManager, | ||||||
|  |         pushNotificationScheduler, | ||||||
|         auth, |         auth, | ||||||
|         client, |         client, | ||||||
|         DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS, |         DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS, | ||||||
|  | @ -171,6 +175,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
|       MessagesManager messagesManager, |       MessagesManager messagesManager, | ||||||
|       MessageMetrics messageMetrics, |       MessageMetrics messageMetrics, | ||||||
|       PushNotificationManager pushNotificationManager, |       PushNotificationManager pushNotificationManager, | ||||||
|  |       PushNotificationScheduler pushNotificationScheduler, | ||||||
|       AuthenticatedDevice auth, |       AuthenticatedDevice auth, | ||||||
|       WebSocketClient client, |       WebSocketClient client, | ||||||
|       int sendFuturesTimeoutMillis, |       int sendFuturesTimeoutMillis, | ||||||
|  | @ -182,6 +187,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
|     this.messagesManager = messagesManager; |     this.messagesManager = messagesManager; | ||||||
|     this.messageMetrics = messageMetrics; |     this.messageMetrics = messageMetrics; | ||||||
|     this.pushNotificationManager = pushNotificationManager; |     this.pushNotificationManager = pushNotificationManager; | ||||||
|  |     this.pushNotificationScheduler = pushNotificationScheduler; | ||||||
|     this.auth = auth; |     this.auth = auth; | ||||||
|     this.client = client; |     this.client = client; | ||||||
|     this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis; |     this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis; | ||||||
|  | @ -211,14 +217,9 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac | ||||||
|     client.close(1000, "OK"); |     client.close(1000, "OK"); | ||||||
| 
 | 
 | ||||||
|     if (storedMessageState.get() != StoredMessageState.EMPTY) { |     if (storedMessageState.get() != StoredMessageState.EMPTY) { | ||||||
|       try { |       pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(), | ||||||
|         pushNotificationManager.sendNewMessageNotification(auth.getAccount(), auth.getAuthenticatedDevice().getId(), true); |           auth.getAuthenticatedDevice(), | ||||||
| 
 |           CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY); | ||||||
|         Metrics.counter(PUSH_NOTIFICATION_ON_CLOSE_COUNTER_NAME, |  | ||||||
|                 Tags.of(UserAgentTagUtil.getPlatformTag(client.getUserAgent()))) |  | ||||||
|             .increment(); |  | ||||||
|       } catch (NotPushRegisteredException ignored) { |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -33,7 +33,7 @@ import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controll | ||||||
| import org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples; | import org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples; | ||||||
| import org.whispersystems.textsecuregcm.limits.RateLimiters; | import org.whispersystems.textsecuregcm.limits.RateLimiters; | ||||||
| import org.whispersystems.textsecuregcm.push.APNSender; | import org.whispersystems.textsecuregcm.push.APNSender; | ||||||
| import org.whispersystems.textsecuregcm.push.ApnPushNotificationScheduler; | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | ||||||
| import org.whispersystems.textsecuregcm.push.FcmSender; | import org.whispersystems.textsecuregcm.push.FcmSender; | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | @ -245,10 +245,10 @@ record CommandDependencies( | ||||||
|         clock); |         clock); | ||||||
|     APNSender apnSender = new APNSender(apnSenderExecutor, configuration.getApnConfiguration()); |     APNSender apnSender = new APNSender(apnSenderExecutor, configuration.getApnConfiguration()); | ||||||
|     FcmSender fcmSender = new FcmSender(fcmSenderExecutor, configuration.getFcmConfiguration().credentials().value()); |     FcmSender fcmSender = new FcmSender(fcmSenderExecutor, configuration.getFcmConfiguration().credentials().value()); | ||||||
|     ApnPushNotificationScheduler apnPushNotificationScheduler = new ApnPushNotificationScheduler(pushSchedulerCluster, |     PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster, | ||||||
|         apnSender, accountsManager, 0); |         apnSender, fcmSender, accountsManager, 0, 0); | ||||||
|     PushNotificationManager pushNotificationManager = |     PushNotificationManager pushNotificationManager = | ||||||
|         new PushNotificationManager(accountsManager, apnSender, fcmSender, apnPushNotificationScheduler); |         new PushNotificationManager(accountsManager, apnSender, fcmSender, pushNotificationScheduler); | ||||||
|     PushNotificationExperimentSamples pushNotificationExperimentSamples = |     PushNotificationExperimentSamples pushNotificationExperimentSamples = | ||||||
|         new PushNotificationExperimentSamples(dynamoDbAsyncClient, |         new PushNotificationExperimentSamples(dynamoDbAsyncClient, | ||||||
|             configuration.getDynamoDbTables().getPushNotificationExperimentSamples().getTableName(), |             configuration.getDynamoDbTables().getPushNotificationExperimentSamples().getTableName(), | ||||||
|  |  | ||||||
|  | @ -18,12 +18,14 @@ import net.sourceforge.argparse4j.inf.Subparser; | ||||||
| import org.whispersystems.textsecuregcm.WhisperServerConfiguration; | import org.whispersystems.textsecuregcm.WhisperServerConfiguration; | ||||||
| import org.whispersystems.textsecuregcm.metrics.MetricsUtil; | import org.whispersystems.textsecuregcm.metrics.MetricsUtil; | ||||||
| import org.whispersystems.textsecuregcm.push.APNSender; | import org.whispersystems.textsecuregcm.push.APNSender; | ||||||
| import org.whispersystems.textsecuregcm.push.ApnPushNotificationScheduler; | import org.whispersystems.textsecuregcm.push.FcmSender; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler; | import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler; | ||||||
| 
 | 
 | ||||||
| public class ScheduledApnPushNotificationSenderServiceCommand extends ServerCommand<WhisperServerConfiguration> { | public class ScheduledApnPushNotificationSenderServiceCommand extends ServerCommand<WhisperServerConfiguration> { | ||||||
| 
 | 
 | ||||||
|   private static final String WORKER_COUNT = "workers"; |   private static final String WORKER_COUNT = "workers"; | ||||||
|  |   private static final String MAX_CONCURRENCY = "max_concurrency"; | ||||||
| 
 | 
 | ||||||
|   public ScheduledApnPushNotificationSenderServiceCommand() { |   public ScheduledApnPushNotificationSenderServiceCommand() { | ||||||
|     super(new Application<>() { |     super(new Application<>() { | ||||||
|  | @ -38,11 +40,19 @@ public class ScheduledApnPushNotificationSenderServiceCommand extends ServerComm | ||||||
|   @Override |   @Override | ||||||
|   public void configure(final Subparser subparser) { |   public void configure(final Subparser subparser) { | ||||||
|     super.configure(subparser); |     super.configure(subparser); | ||||||
|  | 
 | ||||||
|     subparser.addArgument("--workers") |     subparser.addArgument("--workers") | ||||||
|         .type(Integer.class) |         .type(Integer.class) | ||||||
|         .dest(WORKER_COUNT) |         .dest(WORKER_COUNT) | ||||||
|         .required(true) |         .required(true) | ||||||
|         .help("The number of worker threads"); |         .help("The number of worker threads"); | ||||||
|  | 
 | ||||||
|  |     subparser.addArgument("--max-concurrency") | ||||||
|  |         .type(Integer.class) | ||||||
|  |         .dest(MAX_CONCURRENCY) | ||||||
|  |         .required(false) | ||||||
|  |         .setDefault(16) | ||||||
|  |         .help("The number of concurrent operations per worker thread"); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Override |   @Override | ||||||
|  | @ -63,15 +73,16 @@ public class ScheduledApnPushNotificationSenderServiceCommand extends ServerComm | ||||||
|           }); |           }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     final ExecutorService apnSenderExecutor = environment.lifecycle().executorService(name(getClass(), "apnSender-%d")) |     final ExecutorService pushNotificationSenderExecutor = environment.lifecycle().executorService(name(getClass(), "apnSender-%d")) | ||||||
|         .maxThreads(1).minThreads(1).build(); |         .maxThreads(1).minThreads(1).build(); | ||||||
| 
 | 
 | ||||||
|     final APNSender apnSender = new APNSender(apnSenderExecutor, configuration.getApnConfiguration()); |     final APNSender apnSender = new APNSender(pushNotificationSenderExecutor, configuration.getApnConfiguration()); | ||||||
|     final ApnPushNotificationScheduler apnPushNotificationScheduler = new ApnPushNotificationScheduler( |     final FcmSender fcmSender = new FcmSender(pushNotificationSenderExecutor, configuration.getFcmConfiguration().credentials().value()); | ||||||
|         deps.pushSchedulerCluster(), apnSender, deps.accountsManager(), namespace.getInt(WORKER_COUNT)); |     final PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler( | ||||||
|  |         deps.pushSchedulerCluster(), apnSender, fcmSender, deps.accountsManager(), namespace.getInt(WORKER_COUNT), namespace.getInt(MAX_CONCURRENCY)); | ||||||
| 
 | 
 | ||||||
|     environment.lifecycle().manage(apnSender); |     environment.lifecycle().manage(apnSender); | ||||||
|     environment.lifecycle().manage(apnPushNotificationScheduler); |     environment.lifecycle().manage(pushNotificationScheduler); | ||||||
| 
 | 
 | ||||||
|     MetricsUtil.registerSystemResourceMetrics(environment); |     MetricsUtil.registerSystemResourceMetrics(environment); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -76,6 +76,7 @@ import org.junit.jupiter.api.Test; | ||||||
| import org.junit.jupiter.api.extension.ExtendWith; | import org.junit.jupiter.api.extension.ExtendWith; | ||||||
| import org.junit.jupiter.params.ParameterizedTest; | import org.junit.jupiter.params.ParameterizedTest; | ||||||
| import org.junit.jupiter.params.provider.Arguments; | import org.junit.jupiter.params.provider.Arguments; | ||||||
|  | import org.junit.jupiter.params.provider.CsvSource; | ||||||
| import org.junit.jupiter.params.provider.MethodSource; | import org.junit.jupiter.params.provider.MethodSource; | ||||||
| import org.junit.jupiter.params.provider.ValueSource; | import org.junit.jupiter.params.provider.ValueSource; | ||||||
| import org.junitpioneer.jupiter.cartesian.ArgumentSets; | import org.junitpioneer.jupiter.cartesian.ArgumentSets; | ||||||
|  | @ -109,6 +110,7 @@ import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | ||||||
| import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; | import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; | ||||||
| import org.whispersystems.textsecuregcm.push.MessageSender; | import org.whispersystems.textsecuregcm.push.MessageSender; | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ReceiptSender; | import org.whispersystems.textsecuregcm.push.ReceiptSender; | ||||||
| import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; | import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; | ||||||
| import org.whispersystems.textsecuregcm.spam.SpamChecker; | import org.whispersystems.textsecuregcm.spam.SpamChecker; | ||||||
|  | @ -179,6 +181,7 @@ class MessageControllerTest { | ||||||
|   private static final CardinalityEstimator cardinalityEstimator = mock(CardinalityEstimator.class); |   private static final CardinalityEstimator cardinalityEstimator = mock(CardinalityEstimator.class); | ||||||
|   private static final RateLimiter rateLimiter = mock(RateLimiter.class); |   private static final RateLimiter rateLimiter = mock(RateLimiter.class); | ||||||
|   private static final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); |   private static final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); | ||||||
|  |   private static final PushNotificationScheduler pushNotificationScheduler = mock(PushNotificationScheduler.class); | ||||||
|   private static final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class); |   private static final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class); | ||||||
|   private static final ExecutorService multiRecipientMessageExecutor = MoreExecutors.newDirectExecutorService(); |   private static final ExecutorService multiRecipientMessageExecutor = MoreExecutors.newDirectExecutorService(); | ||||||
|   private static final Scheduler messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, "messageDelivery"); |   private static final Scheduler messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, "messageDelivery"); | ||||||
|  | @ -200,13 +203,15 @@ class MessageControllerTest { | ||||||
|       .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) |       .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) | ||||||
|       .addResource( |       .addResource( | ||||||
|           new MessageController(rateLimiters, cardinalityEstimator, messageSender, receiptSender, accountsManager, |           new MessageController(rateLimiters, cardinalityEstimator, messageSender, receiptSender, accountsManager, | ||||||
|               messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor, |               messagesManager, pushNotificationManager, pushNotificationScheduler, reportMessageManager, multiRecipientMessageExecutor, | ||||||
|               messageDeliveryScheduler, ReportSpamTokenProvider.noop(), mock(ClientReleaseManager.class), dynamicConfigurationManager, |               messageDeliveryScheduler, ReportSpamTokenProvider.noop(), mock(ClientReleaseManager.class), dynamicConfigurationManager, | ||||||
|               serverSecretParams, SpamChecker.noop(), new MessageMetrics(), clock)) |               serverSecretParams, SpamChecker.noop(), new MessageMetrics(), clock)) | ||||||
|       .build(); |       .build(); | ||||||
| 
 | 
 | ||||||
|   @BeforeEach |   @BeforeEach | ||||||
|   void setup() { |   void setup() { | ||||||
|  |     reset(pushNotificationScheduler); | ||||||
|  | 
 | ||||||
|     final List<Device> singleDeviceList = List.of( |     final List<Device> singleDeviceList = List.of( | ||||||
|         generateTestDevice(SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, SINGLE_DEVICE_PNI_REG_ID1, true) |         generateTestDevice(SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, SINGLE_DEVICE_PNI_REG_ID1, true) | ||||||
|     ); |     ); | ||||||
|  | @ -630,8 +635,13 @@ class MessageControllerTest { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @ParameterizedTest |   @ParameterizedTest | ||||||
|   @MethodSource |   @CsvSource({ | ||||||
|   void testGetMessages(boolean receiveStories) { |       "false, false", | ||||||
|  |       "false, true", | ||||||
|  |       "true, false", | ||||||
|  |       "true, true" | ||||||
|  |   }) | ||||||
|  |   void testGetMessages(final boolean receiveStories, final boolean hasMore) { | ||||||
| 
 | 
 | ||||||
|     final long timestampOne = 313377; |     final long timestampOne = 313377; | ||||||
|     final long timestampTwo = 313388; |     final long timestampTwo = 313388; | ||||||
|  | @ -651,7 +661,7 @@ class MessageControllerTest { | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(AuthHelper.VALID_DEVICE), anyBoolean())) |     when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(AuthHelper.VALID_DEVICE), anyBoolean())) | ||||||
|         .thenReturn(Mono.just(new Pair<>(envelopes, false))); |         .thenReturn(Mono.just(new Pair<>(envelopes, hasMore))); | ||||||
| 
 | 
 | ||||||
|     final String userAgent = "Test-UA"; |     final String userAgent = "Test-UA"; | ||||||
| 
 | 
 | ||||||
|  | @ -685,13 +695,12 @@ class MessageControllerTest { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     verify(pushNotificationManager).handleMessagesRetrieved(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, userAgent); |     verify(pushNotificationManager).handleMessagesRetrieved(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, userAgent); | ||||||
|   } |  | ||||||
| 
 | 
 | ||||||
|   private static Stream<Arguments> testGetMessages() { |     if (hasMore) { | ||||||
|     return Stream.of( |       verify(pushNotificationScheduler).scheduleDelayedNotification(eq(AuthHelper.VALID_ACCOUNT), eq(AuthHelper.VALID_DEVICE), any()); | ||||||
|         Arguments.of(true), |     } else { | ||||||
|         Arguments.of(false) |       verify(pushNotificationScheduler, never()).scheduleDelayedNotification(any(), any(), any()); | ||||||
|     ); |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Test |   @Test | ||||||
|  |  | ||||||
|  | @ -1,252 +0,0 @@ | ||||||
| /* |  | ||||||
|  * Copyright 2021 Signal Messenger, LLC |  | ||||||
|  * SPDX-License-Identifier: AGPL-3.0-only |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| package org.whispersystems.textsecuregcm.push; |  | ||||||
| 
 |  | ||||||
| import static org.junit.jupiter.api.Assertions.assertEquals; |  | ||||||
| import static org.junit.jupiter.api.Assertions.assertFalse; |  | ||||||
| import static org.junit.jupiter.api.Assertions.assertTrue; |  | ||||||
| import static org.mockito.ArgumentMatchers.any; |  | ||||||
| import static org.mockito.Mockito.atLeastOnce; |  | ||||||
| import static org.mockito.Mockito.mock; |  | ||||||
| import static org.mockito.Mockito.never; |  | ||||||
| import static org.mockito.Mockito.verify; |  | ||||||
| import static org.mockito.Mockito.verifyNoInteractions; |  | ||||||
| import static org.mockito.Mockito.when; |  | ||||||
| 
 |  | ||||||
| import io.lettuce.core.cluster.SlotHash; |  | ||||||
| import java.time.Instant; |  | ||||||
| import java.time.temporal.ChronoUnit; |  | ||||||
| import java.util.List; |  | ||||||
| import java.util.Optional; |  | ||||||
| import java.util.UUID; |  | ||||||
| import java.util.concurrent.ExecutionException; |  | ||||||
| import org.apache.commons.lang3.RandomStringUtils; |  | ||||||
| import org.junit.jupiter.api.BeforeEach; |  | ||||||
| import org.junit.jupiter.api.Test; |  | ||||||
| import org.junit.jupiter.api.extension.RegisterExtension; |  | ||||||
| import org.junit.jupiter.params.ParameterizedTest; |  | ||||||
| import org.junit.jupiter.params.provider.CsvSource; |  | ||||||
| import org.mockito.ArgumentCaptor; |  | ||||||
| import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; |  | ||||||
| import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.Account; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.AccountsManager; |  | ||||||
| import org.whispersystems.textsecuregcm.storage.Device; |  | ||||||
| import org.whispersystems.textsecuregcm.util.Pair; |  | ||||||
| import org.whispersystems.textsecuregcm.util.TestClock; |  | ||||||
| 
 |  | ||||||
| class ApnPushNotificationSchedulerTest { |  | ||||||
| 
 |  | ||||||
|   @RegisterExtension |  | ||||||
|   static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); |  | ||||||
| 
 |  | ||||||
|   private Account account; |  | ||||||
|   private Device device; |  | ||||||
| 
 |  | ||||||
|   private APNSender apnSender; |  | ||||||
|   private TestClock clock; |  | ||||||
| 
 |  | ||||||
|   private ApnPushNotificationScheduler apnPushNotificationScheduler; |  | ||||||
| 
 |  | ||||||
|   private static final UUID ACCOUNT_UUID = UUID.randomUUID(); |  | ||||||
|   private static final String ACCOUNT_NUMBER = "+18005551234"; |  | ||||||
|   private static final byte DEVICE_ID = 1; |  | ||||||
|   private static final String APN_ID = RandomStringUtils.randomAlphanumeric(32); |  | ||||||
|   private static final String VOIP_APN_ID = RandomStringUtils.randomAlphanumeric(32); |  | ||||||
| 
 |  | ||||||
|   @BeforeEach |  | ||||||
|   void setUp() throws Exception { |  | ||||||
| 
 |  | ||||||
|     device = mock(Device.class); |  | ||||||
|     when(device.getId()).thenReturn(DEVICE_ID); |  | ||||||
|     when(device.getApnId()).thenReturn(APN_ID); |  | ||||||
|     when(device.getVoipApnId()).thenReturn(VOIP_APN_ID); |  | ||||||
|     when(device.getLastSeen()).thenReturn(System.currentTimeMillis()); |  | ||||||
| 
 |  | ||||||
|     account = mock(Account.class); |  | ||||||
|     when(account.getUuid()).thenReturn(ACCOUNT_UUID); |  | ||||||
|     when(account.getNumber()).thenReturn(ACCOUNT_NUMBER); |  | ||||||
|     when(account.getDevice(DEVICE_ID)).thenReturn(Optional.of(device)); |  | ||||||
| 
 |  | ||||||
|     final AccountsManager accountsManager = mock(AccountsManager.class); |  | ||||||
|     when(accountsManager.getByE164(ACCOUNT_NUMBER)).thenReturn(Optional.of(account)); |  | ||||||
|     when(accountsManager.getByAccountIdentifier(ACCOUNT_UUID)).thenReturn(Optional.of(account)); |  | ||||||
| 
 |  | ||||||
|     apnSender = mock(APNSender.class); |  | ||||||
|     clock = TestClock.now(); |  | ||||||
| 
 |  | ||||||
|     apnPushNotificationScheduler = new ApnPushNotificationScheduler(REDIS_CLUSTER_EXTENSION.getRedisCluster(), |  | ||||||
|         apnSender, accountsManager, clock, 1); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Test |  | ||||||
|   void testClusterInsert() throws ExecutionException, InterruptedException { |  | ||||||
|     final String endpoint = ApnPushNotificationScheduler.getEndpointKey(account, device); |  | ||||||
|     final long currentTimeMillis = System.currentTimeMillis(); |  | ||||||
| 
 |  | ||||||
|     assertTrue( |  | ||||||
|         apnPushNotificationScheduler.getPendingDestinationsForRecurringVoipNotifications(SlotHash.getSlot(endpoint), 1).isEmpty()); |  | ||||||
| 
 |  | ||||||
|     clock.pin(Instant.ofEpochMilli(currentTimeMillis - 30_000)); |  | ||||||
|     apnPushNotificationScheduler.scheduleRecurringVoipNotification(account, device).toCompletableFuture().get(); |  | ||||||
| 
 |  | ||||||
|     clock.pin(Instant.ofEpochMilli(currentTimeMillis)); |  | ||||||
|     final List<String> pendingDestinations = apnPushNotificationScheduler.getPendingDestinationsForRecurringVoipNotifications(SlotHash.getSlot(endpoint), 2); |  | ||||||
|     assertEquals(1, pendingDestinations.size()); |  | ||||||
| 
 |  | ||||||
|     final Optional<Pair<String, Byte>> maybeUuidAndDeviceId = ApnPushNotificationScheduler.getSeparated( |  | ||||||
|         pendingDestinations.get(0)); |  | ||||||
| 
 |  | ||||||
|     assertTrue(maybeUuidAndDeviceId.isPresent()); |  | ||||||
|     assertEquals(ACCOUNT_UUID.toString(), maybeUuidAndDeviceId.get().first()); |  | ||||||
|     assertEquals(DEVICE_ID, maybeUuidAndDeviceId.get().second()); |  | ||||||
| 
 |  | ||||||
|     assertTrue( |  | ||||||
|         apnPushNotificationScheduler.getPendingDestinationsForRecurringVoipNotifications(SlotHash.getSlot(endpoint), 1).isEmpty()); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Test |  | ||||||
|   void testProcessRecurringVoipNotifications() throws ExecutionException, InterruptedException { |  | ||||||
|     final ApnPushNotificationScheduler.NotificationWorker worker = apnPushNotificationScheduler.new NotificationWorker(); |  | ||||||
|     final long currentTimeMillis = System.currentTimeMillis(); |  | ||||||
| 
 |  | ||||||
|     clock.pin(Instant.ofEpochMilli(currentTimeMillis - 30_000)); |  | ||||||
|     apnPushNotificationScheduler.scheduleRecurringVoipNotification(account, device).toCompletableFuture().get(); |  | ||||||
| 
 |  | ||||||
|     clock.pin(Instant.ofEpochMilli(currentTimeMillis)); |  | ||||||
| 
 |  | ||||||
|     final int slot = SlotHash.getSlot(ApnPushNotificationScheduler.getEndpointKey(account, device)); |  | ||||||
| 
 |  | ||||||
|     assertEquals(1, worker.processRecurringVoipNotifications(slot)); |  | ||||||
| 
 |  | ||||||
|     final ArgumentCaptor<PushNotification> notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); |  | ||||||
|     verify(apnSender).sendNotification(notificationCaptor.capture()); |  | ||||||
| 
 |  | ||||||
|     final PushNotification pushNotification = notificationCaptor.getValue(); |  | ||||||
| 
 |  | ||||||
|     assertEquals(VOIP_APN_ID, pushNotification.deviceToken()); |  | ||||||
|     assertEquals(account, pushNotification.destination()); |  | ||||||
|     assertEquals(device, pushNotification.destinationDevice()); |  | ||||||
| 
 |  | ||||||
|     assertEquals(0, worker.processRecurringVoipNotifications(slot)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Test |  | ||||||
|   void testScheduleBackgroundNotificationWithNoRecentNotification() throws ExecutionException, InterruptedException { |  | ||||||
|     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); |  | ||||||
|     clock.pin(now); |  | ||||||
| 
 |  | ||||||
|     assertEquals(Optional.empty(), |  | ||||||
|         apnPushNotificationScheduler.getLastBackgroundNotificationTimestamp(account, device)); |  | ||||||
| 
 |  | ||||||
|     assertEquals(Optional.empty(), |  | ||||||
|         apnPushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(account, device)); |  | ||||||
| 
 |  | ||||||
|     apnPushNotificationScheduler.scheduleBackgroundNotification(account, device).toCompletableFuture().get(); |  | ||||||
| 
 |  | ||||||
|     assertEquals(Optional.of(now), |  | ||||||
|         apnPushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(account, device)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Test |  | ||||||
|   void testScheduleBackgroundNotificationWithRecentNotification() throws ExecutionException, InterruptedException { |  | ||||||
|     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); |  | ||||||
|     final Instant recentNotificationTimestamp = |  | ||||||
|         now.minus(ApnPushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD.dividedBy(2)); |  | ||||||
| 
 |  | ||||||
|     // Insert a timestamp for a recently-sent background push notification |  | ||||||
|     clock.pin(Instant.ofEpochMilli(recentNotificationTimestamp.toEpochMilli())); |  | ||||||
|     apnPushNotificationScheduler.sendBackgroundNotification(account, device); |  | ||||||
| 
 |  | ||||||
|     clock.pin(now); |  | ||||||
|     apnPushNotificationScheduler.scheduleBackgroundNotification(account, device).toCompletableFuture().get(); |  | ||||||
| 
 |  | ||||||
|     final Instant expectedScheduledTimestamp = |  | ||||||
|         recentNotificationTimestamp.plus(ApnPushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD); |  | ||||||
| 
 |  | ||||||
|     assertEquals(Optional.of(expectedScheduledTimestamp), |  | ||||||
|         apnPushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(account, device)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Test |  | ||||||
|   void testProcessScheduledBackgroundNotifications() throws ExecutionException, InterruptedException { |  | ||||||
|     final ApnPushNotificationScheduler.NotificationWorker worker = apnPushNotificationScheduler.new NotificationWorker(); |  | ||||||
| 
 |  | ||||||
|     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); |  | ||||||
| 
 |  | ||||||
|     clock.pin(Instant.ofEpochMilli(now.toEpochMilli())); |  | ||||||
|     apnPushNotificationScheduler.scheduleBackgroundNotification(account, device).toCompletableFuture().get(); |  | ||||||
| 
 |  | ||||||
|     final int slot = |  | ||||||
|         SlotHash.getSlot(ApnPushNotificationScheduler.getPendingBackgroundNotificationQueueKey(account, device)); |  | ||||||
| 
 |  | ||||||
|     clock.pin(Instant.ofEpochMilli(now.minusMillis(1).toEpochMilli())); |  | ||||||
|     assertEquals(0, worker.processScheduledBackgroundNotifications(slot)); |  | ||||||
| 
 |  | ||||||
|     clock.pin(now); |  | ||||||
|     assertEquals(1, worker.processScheduledBackgroundNotifications(slot)); |  | ||||||
| 
 |  | ||||||
|     final ArgumentCaptor<PushNotification> notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); |  | ||||||
|     verify(apnSender).sendNotification(notificationCaptor.capture()); |  | ||||||
| 
 |  | ||||||
|     final PushNotification pushNotification = notificationCaptor.getValue(); |  | ||||||
| 
 |  | ||||||
|     assertEquals(PushNotification.TokenType.APN, pushNotification.tokenType()); |  | ||||||
|     assertEquals(APN_ID, pushNotification.deviceToken()); |  | ||||||
|     assertEquals(account, pushNotification.destination()); |  | ||||||
|     assertEquals(device, pushNotification.destinationDevice()); |  | ||||||
|     assertEquals(PushNotification.NotificationType.NOTIFICATION, pushNotification.notificationType()); |  | ||||||
|     assertFalse(pushNotification.urgent()); |  | ||||||
| 
 |  | ||||||
|     assertEquals(0, worker.processRecurringVoipNotifications(slot)); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @Test |  | ||||||
|   void testProcessScheduledBackgroundNotificationsCancelled() throws ExecutionException, InterruptedException { |  | ||||||
|     final ApnPushNotificationScheduler.NotificationWorker worker = apnPushNotificationScheduler.new NotificationWorker(); |  | ||||||
| 
 |  | ||||||
|     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); |  | ||||||
| 
 |  | ||||||
|     clock.pin(now); |  | ||||||
|     apnPushNotificationScheduler.scheduleBackgroundNotification(account, device).toCompletableFuture().get(); |  | ||||||
|     apnPushNotificationScheduler.cancelScheduledNotifications(account, device).toCompletableFuture().get(); |  | ||||||
| 
 |  | ||||||
|     final int slot = |  | ||||||
|         SlotHash.getSlot(ApnPushNotificationScheduler.getPendingBackgroundNotificationQueueKey(account, device)); |  | ||||||
| 
 |  | ||||||
|     assertEquals(0, worker.processScheduledBackgroundNotifications(slot)); |  | ||||||
| 
 |  | ||||||
|     verify(apnSender, never()).sendNotification(any()); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @ParameterizedTest |  | ||||||
|   @CsvSource({ |  | ||||||
|       "1, true", |  | ||||||
|       "0, false", |  | ||||||
|   }) |  | ||||||
|   void testDedicatedProcessDynamicConfiguration(final int dedicatedThreadCount, final boolean expectActivity) |  | ||||||
|       throws Exception { |  | ||||||
| 
 |  | ||||||
|     final FaultTolerantRedisCluster redisCluster = mock(FaultTolerantRedisCluster.class); |  | ||||||
|     when(redisCluster.withCluster(any())).thenReturn(0L); |  | ||||||
| 
 |  | ||||||
|     final AccountsManager accountsManager = mock(AccountsManager.class); |  | ||||||
| 
 |  | ||||||
|     apnPushNotificationScheduler = new ApnPushNotificationScheduler(redisCluster, apnSender, |  | ||||||
|         accountsManager, dedicatedThreadCount); |  | ||||||
| 
 |  | ||||||
|     apnPushNotificationScheduler.start(); |  | ||||||
|     apnPushNotificationScheduler.stop(); |  | ||||||
| 
 |  | ||||||
|     if (expectActivity) { |  | ||||||
|       verify(redisCluster, atLeastOnce()).withCluster(any()); |  | ||||||
|     } else { |  | ||||||
|       verifyNoInteractions(redisCluster); |  | ||||||
|       verifyNoInteractions(accountsManager); |  | ||||||
|       verifyNoInteractions(apnSender); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  | @ -33,7 +33,7 @@ class PushNotificationManagerTest { | ||||||
|   private AccountsManager accountsManager; |   private AccountsManager accountsManager; | ||||||
|   private APNSender apnSender; |   private APNSender apnSender; | ||||||
|   private FcmSender fcmSender; |   private FcmSender fcmSender; | ||||||
|   private ApnPushNotificationScheduler apnPushNotificationScheduler; |   private PushNotificationScheduler pushNotificationScheduler; | ||||||
| 
 | 
 | ||||||
|   private PushNotificationManager pushNotificationManager; |   private PushNotificationManager pushNotificationManager; | ||||||
| 
 | 
 | ||||||
|  | @ -42,12 +42,12 @@ class PushNotificationManagerTest { | ||||||
|     accountsManager = mock(AccountsManager.class); |     accountsManager = mock(AccountsManager.class); | ||||||
|     apnSender = mock(APNSender.class); |     apnSender = mock(APNSender.class); | ||||||
|     fcmSender = mock(FcmSender.class); |     fcmSender = mock(FcmSender.class); | ||||||
|     apnPushNotificationScheduler = mock(ApnPushNotificationScheduler.class); |     pushNotificationScheduler = mock(PushNotificationScheduler.class); | ||||||
| 
 | 
 | ||||||
|     AccountsHelper.setupMockUpdate(accountsManager); |     AccountsHelper.setupMockUpdate(accountsManager); | ||||||
| 
 | 
 | ||||||
|     pushNotificationManager = |     pushNotificationManager = | ||||||
|         new PushNotificationManager(accountsManager, apnSender, fcmSender, apnPushNotificationScheduler); |         new PushNotificationManager(accountsManager, apnSender, fcmSender, pushNotificationScheduler); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @ParameterizedTest |   @ParameterizedTest | ||||||
|  | @ -152,7 +152,7 @@ class PushNotificationManagerTest { | ||||||
|     verifyNoInteractions(apnSender); |     verifyNoInteractions(apnSender); | ||||||
|     verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); |     verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); | ||||||
|     verify(device, never()).setGcmId(any()); |     verify(device, never()).setGcmId(any()); | ||||||
|     verifyNoInteractions(apnPushNotificationScheduler); |     verifyNoInteractions(pushNotificationScheduler); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @ParameterizedTest |   @ParameterizedTest | ||||||
|  | @ -171,7 +171,7 @@ class PushNotificationManagerTest { | ||||||
|         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); |         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); | ||||||
| 
 | 
 | ||||||
|     if (!urgent) { |     if (!urgent) { | ||||||
|       when(apnPushNotificationScheduler.scheduleBackgroundNotification(account, device)) |       when(pushNotificationScheduler.scheduleBackgroundApnsNotification(account, device)) | ||||||
|           .thenReturn(CompletableFuture.completedFuture(null)); |           .thenReturn(CompletableFuture.completedFuture(null)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -181,10 +181,10 @@ class PushNotificationManagerTest { | ||||||
| 
 | 
 | ||||||
|     if (urgent) { |     if (urgent) { | ||||||
|       verify(apnSender).sendNotification(pushNotification); |       verify(apnSender).sendNotification(pushNotification); | ||||||
|       verifyNoInteractions(apnPushNotificationScheduler); |       verifyNoInteractions(pushNotificationScheduler); | ||||||
|     } else { |     } else { | ||||||
|       verifyNoInteractions(apnSender); |       verifyNoInteractions(apnSender); | ||||||
|       verify(apnPushNotificationScheduler).scheduleBackgroundNotification(account, device); |       verify(pushNotificationScheduler).scheduleBackgroundApnsNotification(account, device); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -210,8 +210,8 @@ class PushNotificationManagerTest { | ||||||
|     verifyNoInteractions(fcmSender); |     verifyNoInteractions(fcmSender); | ||||||
|     verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); |     verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); | ||||||
|     verify(device, never()).setGcmId(any()); |     verify(device, never()).setGcmId(any()); | ||||||
|     verify(apnPushNotificationScheduler).scheduleRecurringVoipNotification(account, device); |     verify(pushNotificationScheduler).scheduleRecurringApnsVoipNotification(account, device); | ||||||
|     verify(apnPushNotificationScheduler, never()).scheduleBackgroundNotification(any(), any()); |     verify(pushNotificationScheduler, never()).scheduleBackgroundApnsNotification(any(), any()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Test |   @Test | ||||||
|  | @ -236,7 +236,7 @@ class PushNotificationManagerTest { | ||||||
|     verify(accountsManager).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); |     verify(accountsManager).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); | ||||||
|     verify(device).setGcmId(null); |     verify(device).setGcmId(null); | ||||||
|     verifyNoInteractions(apnSender); |     verifyNoInteractions(apnSender); | ||||||
|     verifyNoInteractions(apnPushNotificationScheduler); |     verifyNoInteractions(pushNotificationScheduler); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Test |   @Test | ||||||
|  | @ -257,7 +257,7 @@ class PushNotificationManagerTest { | ||||||
|     when(apnSender.sendNotification(pushNotification)) |     when(apnSender.sendNotification(pushNotification)) | ||||||
|         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.empty()))); |         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.empty()))); | ||||||
| 
 | 
 | ||||||
|     when(apnPushNotificationScheduler.cancelScheduledNotifications(account, device)) |     when(pushNotificationScheduler.cancelScheduledNotifications(account, device)) | ||||||
|         .thenReturn(CompletableFuture.completedFuture(null)); |         .thenReturn(CompletableFuture.completedFuture(null)); | ||||||
| 
 | 
 | ||||||
|     pushNotificationManager.sendNotification(pushNotification); |     pushNotificationManager.sendNotification(pushNotification); | ||||||
|  | @ -266,7 +266,7 @@ class PushNotificationManagerTest { | ||||||
|     verify(accountsManager).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); |     verify(accountsManager).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); | ||||||
|     verify(device).setVoipApnId(null); |     verify(device).setVoipApnId(null); | ||||||
|     verify(device, never()).setApnId(any()); |     verify(device, never()).setApnId(any()); | ||||||
|     verify(apnPushNotificationScheduler).cancelScheduledNotifications(account, device); |     verify(pushNotificationScheduler).cancelScheduledNotifications(account, device); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Test |   @Test | ||||||
|  | @ -290,7 +290,7 @@ class PushNotificationManagerTest { | ||||||
|     when(apnSender.sendNotification(pushNotification)) |     when(apnSender.sendNotification(pushNotification)) | ||||||
|         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.of(tokenTimestamp.minusSeconds(60))))); |         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.of(tokenTimestamp.minusSeconds(60))))); | ||||||
| 
 | 
 | ||||||
|     when(apnPushNotificationScheduler.cancelScheduledNotifications(account, device)) |     when(pushNotificationScheduler.cancelScheduledNotifications(account, device)) | ||||||
|         .thenReturn(CompletableFuture.completedFuture(null)); |         .thenReturn(CompletableFuture.completedFuture(null)); | ||||||
| 
 | 
 | ||||||
|     pushNotificationManager.sendNotification(pushNotification); |     pushNotificationManager.sendNotification(pushNotification); | ||||||
|  | @ -299,7 +299,7 @@ class PushNotificationManagerTest { | ||||||
|     verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); |     verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any()); | ||||||
|     verify(device, never()).setVoipApnId(any()); |     verify(device, never()).setVoipApnId(any()); | ||||||
|     verify(device, never()).setApnId(any()); |     verify(device, never()).setApnId(any()); | ||||||
|     verify(apnPushNotificationScheduler, never()).cancelScheduledNotifications(account, device); |     verify(pushNotificationScheduler, never()).cancelScheduledNotifications(account, device); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Test |   @Test | ||||||
|  | @ -312,11 +312,11 @@ class PushNotificationManagerTest { | ||||||
|     when(account.getUuid()).thenReturn(accountIdentifier); |     when(account.getUuid()).thenReturn(accountIdentifier); | ||||||
|     when(device.getId()).thenReturn(Device.PRIMARY_ID); |     when(device.getId()).thenReturn(Device.PRIMARY_ID); | ||||||
| 
 | 
 | ||||||
|     when(apnPushNotificationScheduler.cancelScheduledNotifications(account, device)) |     when(pushNotificationScheduler.cancelScheduledNotifications(account, device)) | ||||||
|         .thenReturn(CompletableFuture.completedFuture(null)); |         .thenReturn(CompletableFuture.completedFuture(null)); | ||||||
| 
 | 
 | ||||||
|     pushNotificationManager.handleMessagesRetrieved(account, device, userAgent); |     pushNotificationManager.handleMessagesRetrieved(account, device, userAgent); | ||||||
| 
 | 
 | ||||||
|     verify(apnPushNotificationScheduler).cancelScheduledNotifications(account, device); |     verify(pushNotificationScheduler).cancelScheduledNotifications(account, device); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,327 @@ | ||||||
|  | /* | ||||||
|  |  * Copyright 2021 Signal Messenger, LLC | ||||||
|  |  * SPDX-License-Identifier: AGPL-3.0-only | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | package org.whispersystems.textsecuregcm.push; | ||||||
|  | 
 | ||||||
|  | import static org.junit.jupiter.api.Assertions.assertEquals; | ||||||
|  | import static org.junit.jupiter.api.Assertions.assertFalse; | ||||||
|  | import static org.junit.jupiter.api.Assertions.assertTrue; | ||||||
|  | import static org.mockito.ArgumentMatchers.any; | ||||||
|  | import static org.mockito.Mockito.atLeastOnce; | ||||||
|  | import static org.mockito.Mockito.mock; | ||||||
|  | import static org.mockito.Mockito.never; | ||||||
|  | import static org.mockito.Mockito.verify; | ||||||
|  | import static org.mockito.Mockito.verifyNoInteractions; | ||||||
|  | import static org.mockito.Mockito.when; | ||||||
|  | 
 | ||||||
|  | import io.lettuce.core.cluster.SlotHash; | ||||||
|  | import java.time.Duration; | ||||||
|  | import java.time.Instant; | ||||||
|  | import java.time.temporal.ChronoUnit; | ||||||
|  | import java.util.List; | ||||||
|  | import java.util.Optional; | ||||||
|  | import java.util.UUID; | ||||||
|  | import java.util.concurrent.CompletableFuture; | ||||||
|  | import java.util.concurrent.ExecutionException; | ||||||
|  | import org.apache.commons.lang3.RandomStringUtils; | ||||||
|  | import org.junit.jupiter.api.BeforeEach; | ||||||
|  | import org.junit.jupiter.api.Test; | ||||||
|  | import org.junit.jupiter.api.extension.RegisterExtension; | ||||||
|  | import org.junit.jupiter.params.ParameterizedTest; | ||||||
|  | import org.junit.jupiter.params.provider.CsvSource; | ||||||
|  | import org.mockito.ArgumentCaptor; | ||||||
|  | import org.whispersystems.textsecuregcm.identity.IdentityType; | ||||||
|  | import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; | ||||||
|  | import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; | ||||||
|  | import org.whispersystems.textsecuregcm.storage.Account; | ||||||
|  | import org.whispersystems.textsecuregcm.storage.AccountsManager; | ||||||
|  | import org.whispersystems.textsecuregcm.storage.Device; | ||||||
|  | import org.whispersystems.textsecuregcm.util.Pair; | ||||||
|  | import org.whispersystems.textsecuregcm.util.TestClock; | ||||||
|  | 
 | ||||||
|  | class PushNotificationSchedulerTest { | ||||||
|  | 
 | ||||||
|  |   @RegisterExtension | ||||||
|  |   static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); | ||||||
|  | 
 | ||||||
|  |   private Account account; | ||||||
|  |   private Device device; | ||||||
|  | 
 | ||||||
|  |   private APNSender apnSender; | ||||||
|  |   private FcmSender fcmSender; | ||||||
|  |   private TestClock clock; | ||||||
|  | 
 | ||||||
|  |   private PushNotificationScheduler pushNotificationScheduler; | ||||||
|  | 
 | ||||||
|  |   private static final UUID ACCOUNT_UUID = UUID.randomUUID(); | ||||||
|  |   private static final String ACCOUNT_NUMBER = "+18005551234"; | ||||||
|  |   private static final byte DEVICE_ID = 1; | ||||||
|  |   private static final String APN_ID = RandomStringUtils.randomAlphanumeric(32); | ||||||
|  |   private static final String VOIP_APN_ID = RandomStringUtils.randomAlphanumeric(32); | ||||||
|  | 
 | ||||||
|  |   @BeforeEach | ||||||
|  |   void setUp() throws Exception { | ||||||
|  | 
 | ||||||
|  |     device = mock(Device.class); | ||||||
|  |     when(device.getId()).thenReturn(DEVICE_ID); | ||||||
|  |     when(device.getApnId()).thenReturn(APN_ID); | ||||||
|  |     when(device.getVoipApnId()).thenReturn(VOIP_APN_ID); | ||||||
|  |     when(device.getLastSeen()).thenReturn(System.currentTimeMillis()); | ||||||
|  | 
 | ||||||
|  |     account = mock(Account.class); | ||||||
|  |     when(account.getUuid()).thenReturn(ACCOUNT_UUID); | ||||||
|  |     when(account.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_UUID); | ||||||
|  |     when(account.getNumber()).thenReturn(ACCOUNT_NUMBER); | ||||||
|  |     when(account.getDevice(DEVICE_ID)).thenReturn(Optional.of(device)); | ||||||
|  | 
 | ||||||
|  |     final AccountsManager accountsManager = mock(AccountsManager.class); | ||||||
|  |     when(accountsManager.getByE164(ACCOUNT_NUMBER)).thenReturn(Optional.of(account)); | ||||||
|  |     when(accountsManager.getByAccountIdentifierAsync(ACCOUNT_UUID)) | ||||||
|  |         .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); | ||||||
|  | 
 | ||||||
|  |     apnSender = mock(APNSender.class); | ||||||
|  |     fcmSender = mock(FcmSender.class); | ||||||
|  |     clock = TestClock.now(); | ||||||
|  | 
 | ||||||
|  |     when(apnSender.sendNotification(any())) | ||||||
|  |         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); | ||||||
|  | 
 | ||||||
|  |     when(fcmSender.sendNotification(any())) | ||||||
|  |         .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler = new PushNotificationScheduler(REDIS_CLUSTER_EXTENSION.getRedisCluster(), | ||||||
|  |         apnSender, fcmSender, accountsManager, clock, 1, 1); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testClusterInsert() throws ExecutionException, InterruptedException { | ||||||
|  |     final String endpoint = PushNotificationScheduler.getVoipEndpointKey(ACCOUNT_UUID, DEVICE_ID); | ||||||
|  |     final long currentTimeMillis = System.currentTimeMillis(); | ||||||
|  | 
 | ||||||
|  |     assertTrue( | ||||||
|  |         pushNotificationScheduler.getPendingDestinationsForRecurringApnsVoipNotifications(SlotHash.getSlot(endpoint), 1).isEmpty()); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.ofEpochMilli(currentTimeMillis - 30_000)); | ||||||
|  |     pushNotificationScheduler.scheduleRecurringApnsVoipNotification(account, device).toCompletableFuture().get(); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.ofEpochMilli(currentTimeMillis)); | ||||||
|  |     final List<String> pendingDestinations = pushNotificationScheduler.getPendingDestinationsForRecurringApnsVoipNotifications(SlotHash.getSlot(endpoint), 2); | ||||||
|  |     assertEquals(1, pendingDestinations.size()); | ||||||
|  | 
 | ||||||
|  |     final Pair<UUID, Byte> aciAndDeviceId = | ||||||
|  |         PushNotificationScheduler.decodeAciAndDeviceId(pendingDestinations.getFirst()); | ||||||
|  | 
 | ||||||
|  |     assertEquals(ACCOUNT_UUID, aciAndDeviceId.first()); | ||||||
|  |     assertEquals(DEVICE_ID, aciAndDeviceId.second()); | ||||||
|  | 
 | ||||||
|  |     assertTrue( | ||||||
|  |         pushNotificationScheduler.getPendingDestinationsForRecurringApnsVoipNotifications(SlotHash.getSlot(endpoint), 1).isEmpty()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testProcessRecurringVoipNotifications() throws ExecutionException, InterruptedException { | ||||||
|  |     final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1); | ||||||
|  |     final long currentTimeMillis = System.currentTimeMillis(); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.ofEpochMilli(currentTimeMillis - 30_000)); | ||||||
|  |     pushNotificationScheduler.scheduleRecurringApnsVoipNotification(account, device).toCompletableFuture().get(); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.ofEpochMilli(currentTimeMillis)); | ||||||
|  | 
 | ||||||
|  |     final int slot = SlotHash.getSlot(PushNotificationScheduler.getVoipEndpointKey(ACCOUNT_UUID, DEVICE_ID)); | ||||||
|  | 
 | ||||||
|  |     assertEquals(1, worker.processRecurringApnsVoipNotifications(slot)); | ||||||
|  | 
 | ||||||
|  |     final ArgumentCaptor<PushNotification> notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); | ||||||
|  |     verify(apnSender).sendNotification(notificationCaptor.capture()); | ||||||
|  | 
 | ||||||
|  |     final PushNotification pushNotification = notificationCaptor.getValue(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(VOIP_APN_ID, pushNotification.deviceToken()); | ||||||
|  |     assertEquals(account, pushNotification.destination()); | ||||||
|  |     assertEquals(device, pushNotification.destinationDevice()); | ||||||
|  | 
 | ||||||
|  |     assertEquals(0, worker.processRecurringApnsVoipNotifications(slot)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testScheduleBackgroundNotificationWithNoRecentApnsNotification() throws ExecutionException, InterruptedException { | ||||||
|  |     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); | ||||||
|  |     clock.pin(now); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getLastBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getNextScheduledBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler.scheduleBackgroundApnsNotification(account, device).toCompletableFuture().get(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.of(now), | ||||||
|  |         pushNotificationScheduler.getNextScheduledBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testScheduleBackgroundNotificationWithRecentApnsNotification() throws ExecutionException, InterruptedException { | ||||||
|  |     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); | ||||||
|  |     final Instant recentNotificationTimestamp = | ||||||
|  |         now.minus(PushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD.dividedBy(2)); | ||||||
|  | 
 | ||||||
|  |     // Insert a timestamp for a recently-sent background push notification | ||||||
|  |     clock.pin(Instant.ofEpochMilli(recentNotificationTimestamp.toEpochMilli())); | ||||||
|  |     pushNotificationScheduler.sendBackgroundApnsNotification(account, device); | ||||||
|  | 
 | ||||||
|  |     clock.pin(now); | ||||||
|  |     pushNotificationScheduler.scheduleBackgroundApnsNotification(account, device).toCompletableFuture().get(); | ||||||
|  | 
 | ||||||
|  |     final Instant expectedScheduledTimestamp = | ||||||
|  |         recentNotificationTimestamp.plus(PushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.of(expectedScheduledTimestamp), | ||||||
|  |         pushNotificationScheduler.getNextScheduledBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testCancelBackgroundApnsNotifications() { | ||||||
|  |     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); | ||||||
|  |     clock.pin(now); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler.scheduleBackgroundApnsNotification(account, device).toCompletableFuture().join(); | ||||||
|  |     pushNotificationScheduler.cancelBackgroundApnsNotifications(account, device).join(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getLastBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getNextScheduledBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testProcessScheduledBackgroundNotifications() { | ||||||
|  |     final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1); | ||||||
|  | 
 | ||||||
|  |     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.ofEpochMilli(now.toEpochMilli())); | ||||||
|  |     pushNotificationScheduler.scheduleBackgroundApnsNotification(account, device).toCompletableFuture().join(); | ||||||
|  | 
 | ||||||
|  |     final int slot = | ||||||
|  |         SlotHash.getSlot(PushNotificationScheduler.getPendingBackgroundApnsNotificationQueueKey(account, device)); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.ofEpochMilli(now.minusMillis(1).toEpochMilli())); | ||||||
|  |     assertEquals(0, worker.processScheduledBackgroundApnsNotifications(slot)); | ||||||
|  | 
 | ||||||
|  |     clock.pin(now); | ||||||
|  |     assertEquals(1, worker.processScheduledBackgroundApnsNotifications(slot)); | ||||||
|  | 
 | ||||||
|  |     final ArgumentCaptor<PushNotification> notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); | ||||||
|  |     verify(apnSender).sendNotification(notificationCaptor.capture()); | ||||||
|  | 
 | ||||||
|  |     final PushNotification pushNotification = notificationCaptor.getValue(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(PushNotification.TokenType.APN, pushNotification.tokenType()); | ||||||
|  |     assertEquals(APN_ID, pushNotification.deviceToken()); | ||||||
|  |     assertEquals(account, pushNotification.destination()); | ||||||
|  |     assertEquals(device, pushNotification.destinationDevice()); | ||||||
|  |     assertEquals(PushNotification.NotificationType.NOTIFICATION, pushNotification.notificationType()); | ||||||
|  |     assertFalse(pushNotification.urgent()); | ||||||
|  | 
 | ||||||
|  |     assertEquals(0, worker.processRecurringApnsVoipNotifications(slot)); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getNextScheduledBackgroundApnsNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testProcessScheduledBackgroundNotificationsCancelled() throws ExecutionException, InterruptedException { | ||||||
|  |     final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1); | ||||||
|  | 
 | ||||||
|  |     final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); | ||||||
|  | 
 | ||||||
|  |     clock.pin(now); | ||||||
|  |     pushNotificationScheduler.scheduleBackgroundApnsNotification(account, device).toCompletableFuture().get(); | ||||||
|  |     pushNotificationScheduler.cancelScheduledNotifications(account, device).toCompletableFuture().get(); | ||||||
|  | 
 | ||||||
|  |     final int slot = | ||||||
|  |         SlotHash.getSlot(PushNotificationScheduler.getPendingBackgroundApnsNotificationQueueKey(account, device)); | ||||||
|  | 
 | ||||||
|  |     assertEquals(0, worker.processScheduledBackgroundApnsNotifications(slot)); | ||||||
|  | 
 | ||||||
|  |     verify(apnSender, never()).sendNotification(any()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testScheduleDelayedNotification() { | ||||||
|  |     clock.pin(Instant.now()); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device)); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(1)).join(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.of(clock.instant().truncatedTo(ChronoUnit.MILLIS).plus(Duration.ofMinutes(1))), | ||||||
|  |         pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device)); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(2)).join(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.of(clock.instant().truncatedTo(ChronoUnit.MILLIS).plus(Duration.ofMinutes(2))), | ||||||
|  |         pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testCancelDelayedNotification() { | ||||||
|  |     pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(1)).join(); | ||||||
|  |     pushNotificationScheduler.cancelDelayedNotifications(account, device).join(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @Test | ||||||
|  |   void testProcessScheduledDelayedNotifications() { | ||||||
|  |     final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1); | ||||||
|  |     final int slot = SlotHash.getSlot(PushNotificationScheduler.getDelayedNotificationQueueKey(account, device)); | ||||||
|  | 
 | ||||||
|  |     clock.pin(Instant.now()); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(1)).join(); | ||||||
|  | 
 | ||||||
|  |     assertEquals(0, worker.processScheduledDelayedNotifications(slot)); | ||||||
|  | 
 | ||||||
|  |     clock.pin(clock.instant().plus(Duration.ofMinutes(1))); | ||||||
|  | 
 | ||||||
|  |     assertEquals(1, worker.processScheduledDelayedNotifications(slot)); | ||||||
|  |     assertEquals(Optional.empty(), | ||||||
|  |         pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @ParameterizedTest | ||||||
|  |   @CsvSource({ | ||||||
|  |       "1, true", | ||||||
|  |       "0, false", | ||||||
|  |   }) | ||||||
|  |   void testDedicatedProcessDynamicConfiguration(final int dedicatedThreadCount, final boolean expectActivity) | ||||||
|  |       throws Exception { | ||||||
|  | 
 | ||||||
|  |     final FaultTolerantRedisCluster redisCluster = mock(FaultTolerantRedisCluster.class); | ||||||
|  |     when(redisCluster.withCluster(any())).thenReturn(0L); | ||||||
|  | 
 | ||||||
|  |     final AccountsManager accountsManager = mock(AccountsManager.class); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler = new PushNotificationScheduler(redisCluster, apnSender, fcmSender, | ||||||
|  |         accountsManager, dedicatedThreadCount, 1); | ||||||
|  | 
 | ||||||
|  |     pushNotificationScheduler.start(); | ||||||
|  |     pushNotificationScheduler.stop(); | ||||||
|  | 
 | ||||||
|  |     if (expectActivity) { | ||||||
|  |       verify(redisCluster, atLeastOnce()).withCluster(any()); | ||||||
|  |     } else { | ||||||
|  |       verifyNoInteractions(redisCluster); | ||||||
|  |       verifyNoInteractions(accountsManager); | ||||||
|  |       verifyNoInteractions(apnSender); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -48,6 +48,7 @@ import org.whispersystems.textsecuregcm.entities.MessageProtos; | ||||||
| import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; | import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; | ||||||
| import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ReceiptSender; | import org.whispersystems.textsecuregcm.push.ReceiptSender; | ||||||
| import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; | import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; | ||||||
| import org.whispersystems.textsecuregcm.storage.Account; | import org.whispersystems.textsecuregcm.storage.Account; | ||||||
|  | @ -127,6 +128,7 @@ class WebSocketConnectionIntegrationTest { | ||||||
|         new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), |         new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), | ||||||
|         new MessageMetrics(), |         new MessageMetrics(), | ||||||
|         mock(PushNotificationManager.class), |         mock(PushNotificationManager.class), | ||||||
|  |         mock(PushNotificationScheduler.class), | ||||||
|         new AuthenticatedDevice(account, device), |         new AuthenticatedDevice(account, device), | ||||||
|         webSocketClient, |         webSocketClient, | ||||||
|         scheduledExecutorService, |         scheduledExecutorService, | ||||||
|  | @ -213,6 +215,7 @@ class WebSocketConnectionIntegrationTest { | ||||||
|         new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), |         new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), | ||||||
|         new MessageMetrics(), |         new MessageMetrics(), | ||||||
|         mock(PushNotificationManager.class), |         mock(PushNotificationManager.class), | ||||||
|  |         mock(PushNotificationScheduler.class), | ||||||
|         new AuthenticatedDevice(account, device), |         new AuthenticatedDevice(account, device), | ||||||
|         webSocketClient, |         webSocketClient, | ||||||
|         scheduledExecutorService, |         scheduledExecutorService, | ||||||
|  | @ -280,6 +283,7 @@ class WebSocketConnectionIntegrationTest { | ||||||
|         new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), |         new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), | ||||||
|         new MessageMetrics(), |         new MessageMetrics(), | ||||||
|         mock(PushNotificationManager.class), |         mock(PushNotificationManager.class), | ||||||
|  |         mock(PushNotificationScheduler.class), | ||||||
|         new AuthenticatedDevice(account, device), |         new AuthenticatedDevice(account, device), | ||||||
|         webSocketClient, |         webSocketClient, | ||||||
|         100, // use a very short timeout, so that this test completes quickly |         100, // use a very short timeout, so that this test completes quickly | ||||||
|  |  | ||||||
|  | @ -59,6 +59,7 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; | ||||||
| import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | import org.whispersystems.textsecuregcm.metrics.MessageMetrics; | ||||||
| import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | import org.whispersystems.textsecuregcm.push.ClientPresenceManager; | ||||||
| import org.whispersystems.textsecuregcm.push.PushNotificationManager; | import org.whispersystems.textsecuregcm.push.PushNotificationManager; | ||||||
|  | import org.whispersystems.textsecuregcm.push.PushNotificationScheduler; | ||||||
| import org.whispersystems.textsecuregcm.push.ReceiptSender; | import org.whispersystems.textsecuregcm.push.ReceiptSender; | ||||||
| import org.whispersystems.textsecuregcm.storage.Account; | import org.whispersystems.textsecuregcm.storage.Account; | ||||||
| import org.whispersystems.textsecuregcm.storage.AccountsManager; | import org.whispersystems.textsecuregcm.storage.AccountsManager; | ||||||
|  | @ -122,8 +123,8 @@ class WebSocketConnectionTest { | ||||||
|     WebSocketAccountAuthenticator webSocketAuthenticator = |     WebSocketAccountAuthenticator webSocketAuthenticator = | ||||||
|         new WebSocketAccountAuthenticator(accountAuthenticator, mock(PrincipalSupplier.class)); |         new WebSocketAccountAuthenticator(accountAuthenticator, mock(PrincipalSupplier.class)); | ||||||
|     AuthenticatedConnectListener connectListener = new AuthenticatedConnectListener(receiptSender, messagesManager, |     AuthenticatedConnectListener connectListener = new AuthenticatedConnectListener(receiptSender, messagesManager, | ||||||
|         new MessageMetrics(), mock(PushNotificationManager.class), mock(ClientPresenceManager.class), |         new MessageMetrics(), mock(PushNotificationManager.class), mock(PushNotificationScheduler.class), | ||||||
|         retrySchedulingExecutor, messageDeliveryScheduler, clientReleaseManager); |         mock(ClientPresenceManager.class), retrySchedulingExecutor, messageDeliveryScheduler, clientReleaseManager); | ||||||
|     WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class); |     WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class); | ||||||
| 
 | 
 | ||||||
|     when(accountAuthenticator.authenticate(eq(new BasicCredentials(VALID_USER, VALID_PASSWORD)))) |     when(accountAuthenticator.authenticate(eq(new BasicCredentials(VALID_USER, VALID_PASSWORD)))) | ||||||
|  | @ -626,7 +627,7 @@ class WebSocketConnectionTest { | ||||||
| 
 | 
 | ||||||
|   private @NotNull WebSocketConnection webSocketConnection(final WebSocketClient client) { |   private @NotNull WebSocketConnection webSocketConnection(final WebSocketClient client) { | ||||||
|     return new WebSocketConnection(receiptSender, messagesManager, new MessageMetrics(), |     return new WebSocketConnection(receiptSender, messagesManager, new MessageMetrics(), | ||||||
|         mock(PushNotificationManager.class), auth, client, |         mock(PushNotificationManager.class), mock(PushNotificationScheduler.class), auth, client, | ||||||
|         retrySchedulingExecutor, Schedulers.immediate(), clientReleaseManager); |         retrySchedulingExecutor, Schedulers.immediate(), clientReleaseManager); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	 Jon Chambers
						Jon Chambers