Observe sharedprefs for banner updates.

This commit is contained in:
Nicholas Tinsley
2024-08-22 15:44:44 -04:00
committed by mtang-signal
parent 244a81ef24
commit d15bb05ae3
11 changed files with 157 additions and 135 deletions

View File

@@ -9,7 +9,9 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
@@ -31,17 +33,24 @@ class ServiceOutageBanner(outageInProgress: Boolean) : Banner() {
)
}
companion object {
/**
* A class that can be held by a listener but still produce new [ServiceOutageBanner] in its flow.
* Designed for being called upon by a listener that is listening to changes in [TextSecurePreferences]
*/
class Producer(private val context: Context) {
private val _flow = MutableSharedFlow<Boolean>(replay = 1)
val flow: Flow<ServiceOutageBanner> = _flow.map { ServiceOutageBanner(context) }
@JvmStatic
fun createOneShotFlow(context: Context): Flow<ServiceOutageBanner> = createAndEmit {
ServiceOutageBanner(context)
init {
queryAndEmit()
}
/**
* Take a [Flow] of [Boolean] values representing the service status and map it into a [Flow] of [ServiceOutageBanner]
*/
@JvmStatic
fun fromFlow(statusFlow: Flow<Boolean>): Flow<ServiceOutageBanner> = statusFlow.map { ServiceOutageBanner(it) }
fun queryAndEmit() {
_flow.tryEmit(TextSecurePreferences.getServiceOutage(context))
}
}
companion object {
private val TAG = Log.tag(ServiceOutageBanner::class)
}
}

View File

@@ -9,6 +9,9 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -18,6 +21,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.TextSecurePreferences
/**
* A banner displayed when the client is unauthorized (deregistered).
*/
class UnauthorizedBanner(val context: Context) : Banner() {
override val enabled = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
@@ -37,11 +43,24 @@ class UnauthorizedBanner(val context: Context) : Banner() {
)
}
companion object {
/**
* A class that can be held by a listener but still produce new [UnauthorizedBanner] in its flow.
* Designed for being called upon by a listener that is listening to changes in [TextSecurePreferences]
*/
class Producer(private val context: Context) {
private val _flow = MutableSharedFlow<Boolean>(replay = 1)
val flow: Flow<UnauthorizedBanner> = _flow.map { UnauthorizedBanner(context) }
@JvmStatic
fun createFlow(context: Context): Flow<UnauthorizedBanner> = createAndEmit {
UnauthorizedBanner(context)
init {
queryAndEmit()
}
fun queryAndEmit() {
_flow.tryEmit(TextSecurePreferences.isUnauthorizedReceived(context))
}
}
companion object {
private val TAG = Log.tag(UnauthorizedBanner::class)
}
}

View File

@@ -35,6 +35,8 @@ import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SharedPreferencesLifecycleObserver
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@@ -73,11 +75,21 @@ class AppSettingsFragment : DSLSettingsFragment(
}
private fun updateBanners() {
val unauthorizedProducer = UnauthorizedBanner.Producer(requireContext())
lifecycle.addObserver(
SharedPreferencesLifecycleObserver(
requireContext(),
mapOf(
TextSecurePreferences.UNAUTHORIZED_RECEIVED to { unauthorizedProducer.queryAndEmit() }
)
)
)
val bannerFlows = listOf(
OutdatedBuildBanner.createFlow(requireContext(), OutdatedBuildBanner.ExpiryStatus.EXPIRED_ONLY),
UnauthorizedBanner.createFlow(requireContext())
unauthorizedProducer.flow
)
val bannerManager = BannerManager(bannerFlows,
val bannerManager = BannerManager(
bannerFlows,
onNewBannerShownListener = {
if (bannerView.resolved()) {
bannerView.get().addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->

View File

@@ -106,6 +106,8 @@ import org.thoughtcrime.securesms.badges.gifts.OpenableGift
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration
import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet
import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomSheet
import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
import org.thoughtcrime.securesms.components.AnimatingToggle
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar
@@ -227,6 +229,7 @@ import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBotto
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.invites.InviteActions
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
@@ -306,7 +309,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.SharedPreferencesLifecycleObserver
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -1019,13 +1022,22 @@ class ConversationFragment :
val conversationBannerListener = ConversationBannerListener()
binding.conversationBanner.listener = conversationBannerListener
val serviceOutageObserver = ServiceOutageObserver(requireContext())
lifecycle.addObserver(serviceOutageObserver)
val unauthorizedProducer = UnauthorizedBanner.Producer(requireContext())
val serviceOutageProducer = ServiceOutageBanner.Producer(requireContext())
lifecycle.addObserver(
SharedPreferencesLifecycleObserver(
requireContext(),
mapOf(
TextSecurePreferences.UNAUTHORIZED_RECEIVED to { unauthorizedProducer.queryAndEmit() },
TextSecurePreferences.SERVICE_OUTAGE to { serviceOutageProducer.queryAndEmit() }
)
)
)
val bannerFlows = viewModel.getBannerFlows(
context = requireContext(),
serviceOutageStatusFlow = serviceOutageObserver.flow,
unauthorizedFlow = unauthorizedProducer.flow,
serviceOutageStatusFlow = serviceOutageProducer.flow,
groupJoinClickListener = conversationBannerListener::reviewJoinRequestsAction,
onAddMembers = {
conversationGroupViewModel.groupRecordSnapshot?.let { groupRecord ->
@@ -1038,6 +1050,10 @@ class ConversationFragment :
binding.conversationBanner.collectAndShowBanners(bannerFlows)
if (TextSecurePreferences.getServiceOutage(context)) {
AppDependencies.jobManager.add(ServiceOutageDetectionJob())
}
viewModel
.identityRecordsObservable
.distinctUntilChanged()

View File

@@ -310,7 +310,7 @@ class ConversationViewModel(
}
@OptIn(ExperimentalCoroutinesApi::class)
fun getBannerFlows(context: Context, serviceOutageStatusFlow: Flow<Boolean>, groupJoinClickListener: () -> Unit, onAddMembers: () -> Unit, onNoThanks: () -> Unit, bubbleClickListener: (Boolean) -> Unit): List<Flow<Banner>> {
fun getBannerFlows(context: Context, unauthorizedFlow: Flow<UnauthorizedBanner>, serviceOutageStatusFlow: Flow<ServiceOutageBanner>, groupJoinClickListener: () -> Unit, onAddMembers: () -> Unit, onNoThanks: () -> Unit, bubbleClickListener: (Boolean) -> Unit): List<Flow<Banner>> {
val pendingGroupJoinFlow: Flow<PendingGroupJoinRequestsBanner> = merge(
flow {
emit(PendingGroupJoinRequestsBanner(false, 0, {}, {}))
@@ -327,8 +327,8 @@ class ConversationViewModel(
return listOf(
OutdatedBuildBanner.createFlow(context, OutdatedBuildBanner.ExpiryStatus.EXPIRED_ONLY),
UnauthorizedBanner.createFlow(context),
ServiceOutageBanner.fromFlow(serviceOutageStatusFlow),
unauthorizedFlow,
serviceOutageStatusFlow,
pendingGroupJoinFlow,
groupV1SuggestionsFlow,
BubbleOptOutBanner.createFlow(inBubble = repository.isInBubble, bubbleClickListener)

View File

@@ -99,8 +99,8 @@ import org.thoughtcrime.securesms.banner.BannerManager;
import org.thoughtcrime.securesms.banner.banners.CdsPermanentErrorBanner;
import org.thoughtcrime.securesms.banner.banners.CdsTemporaryErrorBanner;
import org.thoughtcrime.securesms.banner.banners.DozeBanner;
import org.thoughtcrime.securesms.banner.banners.OutdatedBuildBanner;
import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner;
import org.thoughtcrime.securesms.banner.banners.OutdatedBuildBanner;
import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner;
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner;
import org.thoughtcrime.securesms.banner.banners.UsernameOutOfSyncBanner;
@@ -165,8 +165,8 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.ServiceOutageObserver;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SharedPreferencesLifecycleObserver;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
@@ -184,14 +184,17 @@ import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import kotlin.Unit;
import kotlin.jvm.functions.Function0;
import kotlinx.coroutines.flow.Flow;
import static android.app.Activity.RESULT_OK;
@@ -846,13 +849,26 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void initializeBanners() {
final ServiceOutageObserver serviceOutageObserver = new ServiceOutageObserver(requireContext());
Map<String, Function0<Unit>> listenerMap = new HashMap<>();
final UnauthorizedBanner.Producer unauthorizedBannerProducer = new UnauthorizedBanner.Producer(requireContext());
final ServiceOutageBanner.Producer serviceOutageBannerProducer = new ServiceOutageBanner.Producer(requireContext());
getLifecycle().addObserver(serviceOutageObserver);
listenerMap.put(TextSecurePreferences.UNAUTHORIZED_RECEIVED, () -> {
unauthorizedBannerProducer.queryAndEmit();
return Unit.INSTANCE;
});
listenerMap.put(TextSecurePreferences.SERVICE_OUTAGE, () -> {
serviceOutageBannerProducer.queryAndEmit();
return Unit.INSTANCE;
});
final SharedPreferencesLifecycleObserver sharedPrefsObserver = new SharedPreferencesLifecycleObserver(requireContext(), listenerMap);
getLifecycle().addObserver(sharedPrefsObserver);
final List<Flow<? extends Banner>> bannerRepositories = List.of(OutdatedBuildBanner.createFlow(requireContext(), OutdatedBuildBanner.ExpiryStatus.EXPIRED_ONLY),
UnauthorizedBanner.createFlow(requireContext()),
ServiceOutageBanner.fromFlow(serviceOutageObserver.getFlow()),
unauthorizedBannerProducer.getFlow(),
serviceOutageBannerProducer.getFlow(),
OutdatedBuildBanner.createFlow(requireContext(), OutdatedBuildBanner.ExpiryStatus.OUTDATED_ONLY),
DozeBanner.createFlow(requireContext()),
CdsTemporaryErrorBanner.createFlow(getChildFragmentManager()),

View File

@@ -19,9 +19,9 @@ public class ServiceOutageDetectionJob extends BaseJob {
private static final String TAG = Log.tag(ServiceOutageDetectionJob.class);
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;
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 ServiceOutageDetectionJob() {
this(new Job.Parameters.Builder()

View File

@@ -55,6 +55,8 @@ import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.SharedPreferencesLifecycleObserver
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -139,9 +141,18 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
}
private fun initializeBanners() {
val unauthorizedProducer = UnauthorizedBanner.Producer(requireContext())
lifecycle.addObserver(
SharedPreferencesLifecycleObserver(
requireContext(),
mapOf(
TextSecurePreferences.UNAUTHORIZED_RECEIVED to { unauthorizedProducer.queryAndEmit() }
)
)
)
val bannerFlows = listOf(
OutdatedBuildBanner.createFlow(requireContext(), OutdatedBuildBanner.ExpiryStatus.EXPIRED_ONLY),
UnauthorizedBanner.createFlow(requireContext())
unauthorizedProducer.flow
)
val bannerManager = BannerManager(
bannerFlows,

View File

@@ -1,100 +0,0 @@
/*
* 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<Boolean> = 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 now = System.currentTimeMillis()
TextSecurePreferences.setLastOutageCheckTime(context, now)
val address = InetAddress.getByName(BuildConfig.SIGNAL_SERVICE_STATUS_URL)
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
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
/**
* A lifecycle-aware observer that will let the changes to the [TextSecurePreferences] be observed.
*
* @param keysToListeners a map of [TextSecurePreferences] string keys to listeners that should be invoked when the values change.
*/
class SharedPreferencesLifecycleObserver(private val context: Context, keysToListeners: Map<String, () -> Unit>) : DefaultLifecycleObserver {
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
keysToListeners[key]?.invoke()
}
override fun onResume(owner: LifecycleOwner) {
TextSecurePreferences.registerListener(context, listener)
}
override fun onPause(owner: LifecycleOwner) {
TextSecurePreferences.unregisterListener(context, listener)
}
}

View File

@@ -110,7 +110,7 @@ public class TextSecurePreferences {
public static final String ALWAYS_RELAY_CALLS_PREF = "pref_turn_only";
public static final String READ_RECEIPTS_PREF = "pref_read_receipts";
public static final String INCOGNITO_KEYBORAD_PREF = "pref_incognito_keyboard";
private static final String UNAUTHORIZED_RECEIVED = "pref_unauthorized_received";
public static final String UNAUTHORIZED_RECEIVED = "pref_unauthorized_received";
private static final String SUCCESSFUL_DIRECTORY_PREF = "pref_successful_directory";
private static final String DATABASE_ENCRYPTED_SECRET = "pref_database_encrypted_secret";
@@ -146,7 +146,7 @@ public class TextSecurePreferences {
public static final String SIGNAL_PIN_CHANGE = "pref_kbs_change";
private static final String SERVICE_OUTAGE = "pref_service_outage";
public static final String SERVICE_OUTAGE = "pref_service_outage";
private static final String LAST_OUTAGE_CHECK_TIME = "pref_last_outage_check_time";
private static final String LAST_FULL_CONTACT_SYNC_TIME = "pref_last_full_contact_sync_time";
@@ -293,6 +293,14 @@ public class TextSecurePreferences {
}
}
public static void registerListener(@NonNull Context context, SharedPreferences.OnSharedPreferenceChangeListener listener) {
getSharedPreferences(context).registerOnSharedPreferenceChangeListener(listener);
}
public static void unregisterListener(@NonNull Context context, SharedPreferences.OnSharedPreferenceChangeListener listener) {
getSharedPreferences(context).unregisterOnSharedPreferenceChangeListener(listener);
}
public static boolean isScreenLockEnabled(@NonNull Context context) {
return getBooleanPreference(context, SCREEN_LOCK, false);
}