From 4002dea05d9a6e5366d31dc572ea38b9e8fa9e44 Mon Sep 17 00:00:00 2001 From: Nicholas Tinsley Date: Mon, 19 Aug 2024 13:52:15 -0400 Subject: [PATCH] Observe service outages in a lifecycle-aware fashion. --- .../banner/banners/ServiceOutageBanner.kt | 15 ++- .../conversation/v2/ConversationFragment.kt | 8 +- .../conversation/v2/ConversationViewModel.kt | 4 +- .../ConversationListFragment.java | 7 +- .../jobs/ServiceOutageDetectionJob.java | 6 +- .../securesms/util/ServiceOutageObserver.kt | 100 ++++++++++++++++++ 6 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/ServiceOutageObserver.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ServiceOutageBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ServiceOutageBanner.kt index 154129f492..bec0f1cdc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ServiceOutageBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ServiceOutageBanner.kt @@ -9,15 +9,18 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.banner.Banner import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner import org.thoughtcrime.securesms.banner.ui.compose.Importance import org.thoughtcrime.securesms.util.TextSecurePreferences -class ServiceOutageBanner(context: Context) : Banner() { +class ServiceOutageBanner(outageInProgress: Boolean) : Banner() { - override val enabled = TextSecurePreferences.getServiceOutage(context) + constructor(context: Context) : this(TextSecurePreferences.getServiceOutage(context)) + + override val enabled = outageInProgress @Composable override fun DisplayBanner() { @@ -31,8 +34,14 @@ class ServiceOutageBanner(context: Context) : Banner() { companion object { @JvmStatic - fun createFlow(context: Context): Flow = createAndEmit { + fun createOneShotFlow(context: Context): Flow = createAndEmit { ServiceOutageBanner(context) } + + /** + * Take a [Flow] of [Boolean] values representing the service status and map it into a [Flow] of [ServiceOutageBanner] + */ + @JvmStatic + fun fromFlow(statusFlow: Flow): Flow = statusFlow.map { ServiceOutageBanner(it) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index fd194ec893..5612131f31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -308,6 +308,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil.isValidEditMessage import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SaveAttachmentUtil +import org.thoughtcrime.securesms.util.ServiceOutageObserver import org.thoughtcrime.securesms.util.SignalLocalMetrics import org.thoughtcrime.securesms.util.StorageUtil import org.thoughtcrime.securesms.util.TextSecurePreferences @@ -1020,8 +1021,11 @@ class ConversationFragment : val conversationBannerListener = ConversationBannerListener() binding.conversationBanner.listener = conversationBannerListener if (RemoteConfig.newBannerUi) { + val serviceOutageObserver = ServiceOutageObserver(requireContext()) + val bannerFlows = viewModel.getBannerFlows( context = requireContext(), + serviceOutageStatusFlow = serviceOutageObserver.flow, groupJoinClickListener = conversationBannerListener::reviewJoinRequestsAction, onAddMembers = { conversationGroupViewModel.groupRecordSnapshot?.let { groupRecord -> @@ -1033,10 +1037,6 @@ class ConversationFragment : ) binding.conversationBanner.collectAndShowBanners(bannerFlows) - - if (TextSecurePreferences.getServiceOutage(context)) { - AppDependencies.jobManager.add(ServiceOutageDetectionJob()) - } } else { viewModel .reminder diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 623d7209b6..c2fc12e327 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -323,7 +323,7 @@ class ConversationViewModel( } @OptIn(ExperimentalCoroutinesApi::class) - fun getBannerFlows(context: Context, groupJoinClickListener: () -> Unit, onAddMembers: () -> Unit, onNoThanks: () -> Unit, bubbleClickListener: (Boolean) -> Unit): List> { + fun getBannerFlows(context: Context, serviceOutageStatusFlow: Flow, groupJoinClickListener: () -> Unit, onAddMembers: () -> Unit, onNoThanks: () -> Unit, bubbleClickListener: (Boolean) -> Unit): List> { val pendingGroupJoinFlow: Flow = merge( flow { emit(PendingGroupJoinRequestsBanner(false, 0, {}, {})) @@ -341,7 +341,7 @@ class ConversationViewModel( return listOf( OutdatedBuildBanner.createFlow(context, OutdatedBuildBanner.ExpiryStatus.EXPIRED_ONLY), UnauthorizedBanner.createFlow(context), - ServiceOutageBanner.createFlow(context), + ServiceOutageBanner.fromFlow(serviceOutageStatusFlow), pendingGroupJoinFlow, groupV1SuggestionsFlow, BubbleOptOutBanner.createFlow(inBubble = repository.isInBubble, bubbleClickListener) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index c3a236329a..bae39f5907 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -183,6 +183,7 @@ import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.PlayStoreUtil; import org.thoughtcrime.securesms.util.RemoteConfig; +import org.thoughtcrime.securesms.util.ServiceOutageObserver; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalProxyUtil; @@ -892,9 +893,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode } private void initializeBanners() { + final ServiceOutageObserver serviceOutageObserver = new ServiceOutageObserver(requireContext()); + + getLifecycle().addObserver(serviceOutageObserver); + final List> bannerRepositories = List.of(OutdatedBuildBanner.createFlow(requireContext(), OutdatedBuildBanner.ExpiryStatus.EXPIRED_ONLY), UnauthorizedBanner.createFlow(requireContext()), - ServiceOutageBanner.createFlow(requireContext()), + ServiceOutageBanner.fromFlow(serviceOutageObserver.getFlow()), OutdatedBuildBanner.createFlow(requireContext(), OutdatedBuildBanner.ExpiryStatus.OUTDATED_ONLY), DozeBanner.createFlow(requireContext()), CdsTemporaryErrorBanner.createFlow(getChildFragmentManager()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java index be8fa35e0c..f2ee8ec6d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java @@ -21,9 +21,9 @@ public class ServiceOutageDetectionJob extends BaseJob { private static final String TAG = Log.tag(ServiceOutageDetectionJob.class); - private static final String IP_SUCCESS = "127.0.0.1"; - private static final String IP_FAILURE = "127.0.0.2"; - private static final long CHECK_TIME = 1000 * 60; + public static final String IP_SUCCESS = "127.0.0.1"; + public static final String IP_FAILURE = "127.0.0.2"; + public static final long CHECK_TIME = 1000 * 60; public ServiceOutageDetectionJob() { this(new Job.Parameters.Builder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceOutageObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceOutageObserver.kt new file mode 100644 index 0000000000..1bd4f4440b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceOutageObserver.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.util + +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob +import java.net.InetAddress +import java.net.UnknownHostException + +/** + * A lifecycle aware observer that can be instantiated to monitor the service for outages. + * + * @see [ServiceOutageDetectionJob] + */ +class ServiceOutageObserver(private val context: Context) : DefaultLifecycleObserver { + companion object { + val TAG = Log.tag(ServiceOutageObserver::class) + } + + private var observing = false + + override fun onResume(owner: LifecycleOwner) { + observing = true + } + + override fun onStop(owner: LifecycleOwner) { + observing = false + } + + val flow: Flow = flow { + emit(TextSecurePreferences.getServiceOutage(context)) + TextSecurePreferences.setLastOutageCheckTime(context, System.currentTimeMillis()) + + while (true) { + if (observing && getNextCheckTime(context) <= System.currentTimeMillis()) { + when (queryAvailability()) { + Result.SUCCESS -> { + TextSecurePreferences.setServiceOutage(context, false) + emit(false) + } + + Result.FAILURE -> { + Log.w(TAG, "Service is down.") + TextSecurePreferences.setServiceOutage(context, true) + emit(true) + } + + Result.RETRY_LATER -> { + Log.w(TAG, "Service status check returned an unrecognized IP address. Could be a weird network state. Prompting retry.") + } + } + } + + val nextCheckTime = getNextCheckTime(context) + val now = System.currentTimeMillis() + val delay = nextCheckTime - now + if (delay > 0) { + delay(delay) + } + } + } + + private fun getNextCheckTime(context: Context): Long = TextSecurePreferences.getLastOutageCheckTime(context) + ServiceOutageDetectionJob.CHECK_TIME + + private suspend fun queryAvailability(): Result = withContext(Dispatchers.IO) { + try { + val address = InetAddress.getByName(BuildConfig.SIGNAL_SERVICE_STATUS_URL) + + val now = System.currentTimeMillis() + TextSecurePreferences.setLastOutageCheckTime(context, now) + + if (ServiceOutageDetectionJob.IP_SUCCESS == address.hostAddress) { + Result.SUCCESS + } else if (ServiceOutageDetectionJob.IP_FAILURE == address.hostAddress) { + Result.FAILURE + } else { + Result.RETRY_LATER + } + } catch (e: UnknownHostException) { + Log.i(TAG, "Received UnknownHostException!", e) + Result.RETRY_LATER + } + } + + private enum class Result { + SUCCESS, FAILURE, RETRY_LATER + } +}