Update device-specific notification support configs.
This commit is contained in:
parent
60a0565ba8
commit
9024c19169
14 changed files with 319 additions and 131 deletions
|
@ -20,6 +20,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||||
import org.signal.donations.StripeApi;
|
import org.signal.donations.StripeApi;
|
||||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
||||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
||||||
|
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
|
||||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||||
|
@ -112,7 +113,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case NONE:
|
case NONE:
|
||||||
break;
|
break;
|
||||||
case PROMPT_BATTERY_SAVER_DIALOG:
|
case PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG:
|
||||||
|
DeviceSpecificNotificationBottomSheet.show(getSupportFragmentManager());
|
||||||
|
break;
|
||||||
|
case PROMPT_GENERAL_BATTERY_SAVER_DIALOG:
|
||||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||||
break;
|
break;
|
||||||
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
package org.thoughtcrime.securesms.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import org.signal.core.ui.BottomSheets
|
||||||
|
import org.signal.core.ui.Buttons
|
||||||
|
import org.signal.core.ui.Previews
|
||||||
|
import org.signal.core.ui.SignalPreview
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
|
||||||
|
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||||
|
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||||
|
|
||||||
|
class DeviceSpecificNotificationBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
override val peekHeightPercentage: Float = 0.66f
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_LINK = "arg.link"
|
||||||
|
private const val ARG_LINK_VERSION = "arg.link.version"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun show(fragmentManager: FragmentManager) {
|
||||||
|
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||||
|
DeviceSpecificNotificationBottomSheet().apply {
|
||||||
|
arguments = bundleOf(
|
||||||
|
ARG_LINK to DeviceSpecificNotificationConfig.currentConfig.link,
|
||||||
|
ARG_LINK_VERSION to DeviceSpecificNotificationConfig.currentConfig.version
|
||||||
|
)
|
||||||
|
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||||
|
SignalStore.uiHints.lastSupportVersionSeen = DeviceSpecificNotificationConfig.currentConfig.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun SheetContent() {
|
||||||
|
DeviceSpecificSheet(this::onContinue, this::dismissAllowingStateLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onContinue() {
|
||||||
|
val link = arguments?.getString(ARG_LINK) ?: getString(R.string.PromptBatterySaverBottomSheet__learn_more_url)
|
||||||
|
CommunicationActions.openBrowserLink(requireContext(), link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Unit = {}) {
|
||||||
|
return Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth().wrapContentSize(Alignment.Center)
|
||||||
|
) {
|
||||||
|
BottomSheets.Handle()
|
||||||
|
Icon(
|
||||||
|
painterResource(id = R.drawable.ic_troubleshoot_notification),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Unspecified,
|
||||||
|
modifier = Modifier.padding(top = 32.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.DeviceSpecificNotificationBottomSheet__notifications_may_be_delayed),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.DeviceSpecificNotificationBottomSheet__disable_battery_optimizations),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 60.dp, bottom = 24.dp, start = 24.dp, end = 24.dp)
|
||||||
|
) {
|
||||||
|
Buttons.MediumTonal(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.padding(end = 12.dp).weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.DeviceSpecificNotificationBottomSheet__no_thanks))
|
||||||
|
}
|
||||||
|
Buttons.MediumTonal(
|
||||||
|
onClick = onContinue,
|
||||||
|
modifier = Modifier.padding(start = 12.dp).weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.ic_open_20), contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||||
|
Text(stringResource(id = R.string.DeviceSpecificNotificationBottomSheet__continue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SignalPreview
|
||||||
|
@Composable
|
||||||
|
fun DeviceSpecificSheetPreview() {
|
||||||
|
Previews.BottomSheetPreview {
|
||||||
|
DeviceSpecificSheet()
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,7 +18,7 @@ import org.signal.core.util.logging.Log
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
|
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.notifications.DelayedNotificationConfig
|
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
|
||||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||||
import org.thoughtcrime.securesms.util.LocalMetrics
|
import org.thoughtcrime.securesms.util.LocalMetrics
|
||||||
import org.thoughtcrime.securesms.util.PowerManagerCompat
|
import org.thoughtcrime.securesms.util.PowerManagerCompat
|
||||||
|
@ -35,7 +35,7 @@ class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFrag
|
||||||
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||||
PromptBatterySaverDialogFragment().apply {
|
PromptBatterySaverDialogFragment().apply {
|
||||||
arguments = bundleOf(
|
arguments = bundleOf(
|
||||||
ARG_LEARN_MORE_LINK to DelayedNotificationConfig.currentConfig.link
|
ARG_LEARN_MORE_LINK to DeviceSpecificNotificationConfig.currentConfig.link
|
||||||
)
|
)
|
||||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||||
SignalStore.uiHints.lastBatterySaverPrompt = System.currentTimeMillis()
|
SignalStore.uiHints.lastBatterySaverPrompt = System.currentTimeMillis()
|
||||||
|
|
|
@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||||
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
|
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
|
||||||
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
|
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
|
||||||
|
@ -120,7 +121,8 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
|
||||||
messagePrivacy = SignalStore.settings.messageNotificationsPrivacy.toString(),
|
messagePrivacy = SignalStore.settings.messageNotificationsPrivacy.toString(),
|
||||||
priority = TextSecurePreferences.getNotificationPriority(AppDependencies.application),
|
priority = TextSecurePreferences.getNotificationPriority(AppDependencies.application),
|
||||||
troubleshootNotifications = if (calculateSlowNotifications) {
|
troubleshootNotifications = if (calculateSlowNotifications) {
|
||||||
SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && (SlowNotificationHeuristics.isHavingDelayedNotifications() || SlowNotificationHeuristics.showPreemptively())
|
(SlowNotificationHeuristics.isBatteryOptimizationsOn() && SlowNotificationHeuristics.isHavingDelayedNotifications()) ||
|
||||||
|
SlowNotificationHeuristics.showCondition() == DeviceSpecificNotificationConfig.ShowCondition.ALWAYS
|
||||||
} else if (currentState != null) {
|
} else if (currentState != null) {
|
||||||
currentState.messageNotificationsState.troubleshootNotifications
|
currentState.messageNotificationsState.troubleshootNotifications
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -26,6 +26,7 @@ public class UiHintValues extends SignalStoreValues {
|
||||||
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
|
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
|
||||||
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
|
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
|
||||||
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
|
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
|
||||||
|
private static final String LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
|
||||||
|
|
||||||
UiHintValues(@NonNull KeyValueStore store) {
|
UiHintValues(@NonNull KeyValueStore store) {
|
||||||
super(store);
|
super(store);
|
||||||
|
@ -185,4 +186,18 @@ public class UiHintValues extends SignalStoreValues {
|
||||||
public boolean getHasSeenDeleteSyncEducationSheet() {
|
public boolean getHasSeenDeleteSyncEducationSheet() {
|
||||||
return getBoolean(HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET, false);
|
return getBoolean(HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the last version of the support article for delayed notifications that users have seen. Versions are increased in a remote config.
|
||||||
|
*/
|
||||||
|
public int getLastSupportVersionSeen() {
|
||||||
|
return getInteger(LAST_SUPPORT_VERSION_SEEN, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the version number of the support article that users see if they have device-specific notifications issues
|
||||||
|
*/
|
||||||
|
public void setLastSupportVersionSeen(int version) {
|
||||||
|
putLong(LAST_SUPPORT_VERSION_SEEN, version);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,32 +11,50 @@ import java.io.IOException
|
||||||
/**
|
/**
|
||||||
* Remote configs for a device to show a support screen in an effort to prevent delayed notifications
|
* Remote configs for a device to show a support screen in an effort to prevent delayed notifications
|
||||||
*/
|
*/
|
||||||
object DelayedNotificationConfig {
|
object DeviceSpecificNotificationConfig {
|
||||||
|
|
||||||
private val TAG = Log.tag(DelayedNotificationConfig::class.java)
|
private val TAG = Log.tag(DeviceSpecificNotificationConfig::class.java)
|
||||||
private const val GENERAL_SUPPORT_URL = "https://support.signal.org/hc/articles/360007318711#android_notifications_troubleshooting"
|
private const val GENERAL_SUPPORT_URL = "https://support.signal.org/hc/articles/360007318711#android_notifications_troubleshooting"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
val currentConfig: Config by lazy { computeConfig() }
|
val currentConfig: Config by lazy { computeConfig() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a device model to specific modifications set in order to support better notification
|
* Maps a device model to specific modifications set in order to support better notification
|
||||||
* @param model either exact device model name or model name that ends with a wildcard
|
* @param model either exact device model name or model name that ends with a wildcard
|
||||||
* @param showPreemptively shows support sheet immediately if true or after a vitals failure if not, still dependent on localePercent
|
* @param showConditionCode outlines under which conditions to show the prompt, still dependent on localePercent
|
||||||
* @param link represents the Signal support url that corresponds to this device model
|
* @param link represents the Signal support url that corresponds to this device model
|
||||||
* @param localePercent represents the percent of people who will get this change per country
|
* @param localePercent represents the percent of people who will get this change per country
|
||||||
|
* @param version represents the version of the link being shown and should be incremented if the link or link content changes
|
||||||
*/
|
*/
|
||||||
data class Config(
|
data class Config(
|
||||||
@JsonProperty val model: String = "",
|
@JsonProperty val model: String = "",
|
||||||
@JsonProperty val showPreemptively: Boolean = false,
|
@JsonProperty val showConditionCode: String = "has-slow-notifications",
|
||||||
@JsonProperty val link: String = GENERAL_SUPPORT_URL,
|
@JsonProperty val link: String = GENERAL_SUPPORT_URL,
|
||||||
@JsonProperty val localePercent: String = RemoteConfig.promptBatterySaver
|
@JsonProperty val localePercent: String = "*",
|
||||||
)
|
@JsonProperty val version: Int = 0
|
||||||
|
) {
|
||||||
|
val showCondition: ShowCondition = ShowCondition.fromCode(showConditionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes under which conditions to show device help prompt
|
||||||
|
*/
|
||||||
|
enum class ShowCondition(val code: String) {
|
||||||
|
ALWAYS("always"),
|
||||||
|
HAS_BATTERY_OPTIMIZATION_ON("has-battery-optimization-on"),
|
||||||
|
HAS_SLOW_NOTIFICATIONS("has-slow-notifications");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromCode(code: String) = values().firstOrNull { it.code == code } ?: HAS_SLOW_NOTIFICATIONS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun computeConfig(): Config {
|
fun computeConfig(): Config {
|
||||||
val default = Config()
|
val default = Config()
|
||||||
val serialized = RemoteConfig.promptDelayedNotificationConfig
|
val serialized = RemoteConfig.deviceSpecificNotificationConfig
|
||||||
if (serialized.isNullOrBlank()) {
|
if (serialized.isBlank()) {
|
||||||
return default
|
return default
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,7 +132,7 @@ object SlowNotificationHeuristics {
|
||||||
* true can most definitely be at fault.
|
* true can most definitely be at fault.
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun isPotentiallyCausedByBatteryOptimizations(): Boolean {
|
fun isBatteryOptimizationsOn(): Boolean {
|
||||||
val applicationContext = AppDependencies.application
|
val applicationContext = AppDependencies.application
|
||||||
if (DeviceProperties.getDataSaverState(applicationContext) == DeviceProperties.DataSaverState.ENABLED) {
|
if (DeviceProperties.getDataSaverState(applicationContext) == DeviceProperties.DataSaverState.ENABLED) {
|
||||||
return false
|
return false
|
||||||
|
@ -143,8 +143,12 @@ object SlowNotificationHeuristics {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showPreemptively(): Boolean {
|
fun showCondition(): DeviceSpecificNotificationConfig.ShowCondition {
|
||||||
return DelayedNotificationConfig.currentConfig.showPreemptively
|
return DeviceSpecificNotificationConfig.currentConfig.showCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowDialog(): Boolean {
|
||||||
|
return LocaleRemoteConfig.isDeviceSpecificNotificationEnabled() && SignalStore.uiHints.lastSupportVersionSeen < DeviceSpecificNotificationConfig.currentConfig.version
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasRepeatedFailedServiceStarts(metrics: List<LocalMetricsDatabase.EventMetrics>, minimumEventAgeMs: Long, minimumEventCount: Int, failurePercentage: Float): Boolean {
|
private fun hasRepeatedFailedServiceStarts(metrics: List<LocalMetricsDatabase.EventMetrics>, minimumEventAgeMs: Long, minimumEventCount: Int, failurePercentage: Float): Boolean {
|
||||||
|
|
|
@ -46,17 +46,29 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte
|
||||||
private fun checkHeuristics(): Single<State> {
|
private fun checkHeuristics(): Single<State> {
|
||||||
return Single.fromCallable {
|
return Single.fromCallable {
|
||||||
var state = State.NONE
|
var state = State.NONE
|
||||||
if (SlowNotificationHeuristics.showPreemptively() || SlowNotificationHeuristics.isHavingDelayedNotifications()) {
|
when (SlowNotificationHeuristics.showCondition()) {
|
||||||
if (SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.shouldPromptBatterySaver()) {
|
DeviceSpecificNotificationConfig.ShowCondition.ALWAYS -> {
|
||||||
state = State.PROMPT_BATTERY_SAVER_DIALOG
|
if (SlowNotificationHeuristics.shouldShowDialog()) {
|
||||||
} else if (SlowNotificationHeuristics.shouldPromptUserForLogs()) {
|
state = State.PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG
|
||||||
state = State.PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS
|
}
|
||||||
}
|
}
|
||||||
} else if (LogDatabase.getInstance(context).crashes.anyMatch(patterns = CrashConfig.patterns, promptThreshold = System.currentTimeMillis() - 14.days.inWholeMilliseconds)) {
|
DeviceSpecificNotificationConfig.ShowCondition.HAS_BATTERY_OPTIMIZATION_ON -> {
|
||||||
val timeSinceLastPrompt = System.currentTimeMillis() - SignalStore.uiHints.lastCrashPrompt
|
if (SlowNotificationHeuristics.isBatteryOptimizationsOn()) {
|
||||||
|
state = State.PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeviceSpecificNotificationConfig.ShowCondition.HAS_SLOW_NOTIFICATIONS -> {
|
||||||
|
if (SlowNotificationHeuristics.isHavingDelayedNotifications() && SlowNotificationHeuristics.shouldPromptBatterySaver()) {
|
||||||
|
state = State.PROMPT_GENERAL_BATTERY_SAVER_DIALOG
|
||||||
|
} else if (SlowNotificationHeuristics.isHavingDelayedNotifications() && SlowNotificationHeuristics.shouldPromptUserForLogs()) {
|
||||||
|
state = State.PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS
|
||||||
|
} else if (LogDatabase.getInstance(context).crashes.anyMatch(patterns = CrashConfig.patterns, promptThreshold = System.currentTimeMillis() - 14.days.inWholeMilliseconds)) {
|
||||||
|
val timeSinceLastPrompt = System.currentTimeMillis() - SignalStore.uiHints.lastCrashPrompt
|
||||||
|
|
||||||
if (timeSinceLastPrompt > 1.days.inWholeMilliseconds) {
|
if (timeSinceLastPrompt > 1.days.inWholeMilliseconds) {
|
||||||
state = State.PROMPT_DEBUGLOGS_FOR_CRASH
|
state = State.PROMPT_DEBUGLOGS_FOR_CRASH
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,7 +78,8 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte
|
||||||
|
|
||||||
enum class State {
|
enum class State {
|
||||||
NONE,
|
NONE,
|
||||||
PROMPT_BATTERY_SAVER_DIALOG,
|
PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG,
|
||||||
|
PROMPT_GENERAL_BATTERY_SAVER_DIALOG,
|
||||||
PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS,
|
PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS,
|
||||||
PROMPT_DEBUGLOGS_FOR_CRASH
|
PROMPT_DEBUGLOGS_FOR_CRASH
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mms.PushMediaConstraints;
|
import org.thoughtcrime.securesms.mms.PushMediaConstraints;
|
||||||
import org.thoughtcrime.securesms.notifications.DelayedNotificationConfig;
|
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
@ -75,7 +75,11 @@ public final class LocaleRemoteConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isBatterySaverPromptEnabled() {
|
public static boolean isBatterySaverPromptEnabled() {
|
||||||
return RemoteConfig.internalUser() || isEnabledPartsPerMillion(RemoteConfig.PROMPT_BATTERY_SAVER, DelayedNotificationConfig.INSTANCE.getCurrentConfig().getLocalePercent());
|
return RemoteConfig.internalUser() || isEnabledPartsPerMillion(RemoteConfig.PROMPT_BATTERY_SAVER, RemoteConfig.promptBatterySaver());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDeviceSpecificNotificationEnabled() {
|
||||||
|
return isEnabledPartsPerMillion(RemoteConfig.DEVICE_SPECIFIC_NOTIFICATION_CONFIG, DeviceSpecificNotificationConfig.getCurrentConfig().getLocalePercent());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -874,10 +874,10 @@ object RemoteConfig {
|
||||||
hotSwappable = true
|
hotSwappable = true
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val PROMPT_DELAYED_NOTIFICATION_CONFIG: String = "android.delayedNotificationConfig"
|
const val DEVICE_SPECIFIC_NOTIFICATION_CONFIG: String = "android.deviceSpecificNotificationConfig"
|
||||||
|
|
||||||
val promptDelayedNotificationConfig: String by remoteString(
|
val deviceSpecificNotificationConfig: String by remoteString(
|
||||||
key = PROMPT_DELAYED_NOTIFICATION_CONFIG,
|
key = DEVICE_SPECIFIC_NOTIFICATION_CONFIG,
|
||||||
defaultValue = "",
|
defaultValue = "",
|
||||||
hotSwappable = true
|
hotSwappable = true
|
||||||
)
|
)
|
||||||
|
|
|
@ -1097,6 +1097,15 @@
|
||||||
<!-- Message explaining that battery saver may delay notifications -->
|
<!-- Message explaining that battery saver may delay notifications -->
|
||||||
<string name="PromptBatterySaverBottomSheet__message">You can disable battery optimizations for Signal to ensure that message notifications will not be delayed.</string>
|
<string name="PromptBatterySaverBottomSheet__message">You can disable battery optimizations for Signal to ensure that message notifications will not be delayed.</string>
|
||||||
|
|
||||||
|
<!-- Title in bottom sheet that states that notifications for this device could be delayed -->
|
||||||
|
<string name="DeviceSpecificNotificationBottomSheet__notifications_may_be_delayed">Notifications may be delayed due to battery optimizations</string>
|
||||||
|
<!-- Message in bottom sheet prompting users to fix potential issues by disabling battery optimizations -->
|
||||||
|
<string name="DeviceSpecificNotificationBottomSheet__disable_battery_optimizations">Disable battery optimizations for Signal to ensure that message notifications will not be delayed. Tap “Continue” to see device-specific instructions.</string>
|
||||||
|
<!-- Button to dismiss notification help prompt -->
|
||||||
|
<string name="DeviceSpecificNotificationBottomSheet__no_thanks">No thanks</string>
|
||||||
|
<!-- Button to continue and go to Signal support website -->
|
||||||
|
<string name="DeviceSpecificNotificationBottomSheet__continue">Continue</string>
|
||||||
|
|
||||||
<!-- Button to continue to try and disable battery saver -->
|
<!-- Button to continue to try and disable battery saver -->
|
||||||
<string name="PromptBatterySaverBottomSheet__continue">Continue</string>
|
<string name="PromptBatterySaverBottomSheet__continue">Continue</string>
|
||||||
<!-- Button to dismiss battery saver dialog prompt-->
|
<!-- Button to dismiss battery saver dialog prompt-->
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
package org.thoughtcrime.securesms.notifications
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.Build
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockkObject
|
|
||||||
import io.mockk.unmockkObject
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import org.robolectric.util.ReflectionHelpers
|
|
||||||
import org.thoughtcrime.securesms.assertIs
|
|
||||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(manifest = Config.NONE, application = Application::class)
|
|
||||||
class DelayedNotificationConfigTest {
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
mockkObject(RemoteConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
unmockkObject(RemoteConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty config`() {
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns ""
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `invalid config`() {
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns "bad"
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `simple device match`() {
|
|
||||||
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test")
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns """[ { "model": "test", "link": "test.com", "showPreemptively": true, "localePercent": "*:500000" } ]"""
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config(model = "test", link = "test.com", showPreemptively = true, localePercent = "*:500000")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `complex device match`() {
|
|
||||||
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test-1")
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns
|
|
||||||
"""
|
|
||||||
[
|
|
||||||
{ "model": "test", "showPreemptively": false, "localePercent": "*:10000" },
|
|
||||||
{ "model": "test-1", "showPreemptively": true, "localePercent": "*:20000" },
|
|
||||||
{ "model": "test-11", "showPreemptively": false, "localePercent": "*:30000" },
|
|
||||||
{ "model": "test-11*", "showPreemptively": false, "localePercent": "*:40000" }
|
|
||||||
]
|
|
||||||
""".trimMargin()
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config(model = "test-1", showPreemptively = true, localePercent = "*:20000")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `simple wildcard device match`() {
|
|
||||||
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test1")
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns """[ { "model": "test*", "link": "test.com", "showPreemptively": true, "localePercent": "*:500000" } ]"""
|
|
||||||
DelayedNotificationConfig.currentConfig assertIs DelayedNotificationConfig.Config(model = "test*", link = "test.com", showPreemptively = true, localePercent = "*:500000")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `complex wildcard device match`() {
|
|
||||||
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test-1")
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns
|
|
||||||
"""
|
|
||||||
[
|
|
||||||
{ "model": "*", "showPreemptively": false, "localePercent": "*:10000" },
|
|
||||||
{ "model": "test1", "showPreemptively": false, "localePercent": "*:20000" },
|
|
||||||
{ "model": "test-", "showPreemptively": false, "localePercent": "*:30000" }
|
|
||||||
]
|
|
||||||
""".trimMargin()
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config(model = "*", showPreemptively = false, localePercent = "*:10000")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `no device match`() {
|
|
||||||
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "bad")
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns """[ { "model": "test", "link": "test.com", "showPreemptively": true, "localePercent": "*:500000" } ]"""
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `default fields is zero percent`() {
|
|
||||||
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test")
|
|
||||||
every { RemoteConfig.promptDelayedNotificationConfig } returns """[ { "model": "test" } ]"""
|
|
||||||
DelayedNotificationConfig.computeConfig() assertIs DelayedNotificationConfig.Config(model = "test", showPreemptively = false, localePercent = "*")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
package org.thoughtcrime.securesms.notifications
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkObject
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.util.ReflectionHelpers
|
||||||
|
import org.thoughtcrime.securesms.assertIs
|
||||||
|
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(manifest = Config.NONE, application = Application::class)
|
||||||
|
class DeviceSpecificNotificationConfigTest {
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
mockkObject(RemoteConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkObject(RemoteConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty config`() {
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns ""
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid config`() {
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns "bad"
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `simple device match`() {
|
||||||
|
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test")
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns """[ { "model": "test", "link": "test.com", "showConditionCode": "always", "localePercent": "*:500000", "version": 3 } ]"""
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config(model = "test", link = "test.com", showConditionCode = "always", localePercent = "*:500000", version = 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `complex device match`() {
|
||||||
|
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test-1")
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{ "model": "test", "showConditionCode": "always", "localePercent": "*:10000", "version": 1 },
|
||||||
|
{ "model": "test-1", "showConditionCode": "has-battery-optimization-on", "localePercent": "*:20000", "version": 2 },
|
||||||
|
{ "model": "test-11", "showConditionCode": "has-slow-notifications", "localePercent": "*:30000", "version": 3 },
|
||||||
|
{ "model": "test-11*", "showConditionCode": "never", "localePercent": "*:40000", "version": 4 }
|
||||||
|
]
|
||||||
|
""".trimMargin()
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config(model = "test-1", showConditionCode = "has-battery-optimization-on", localePercent = "*:20000", version = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `simple wildcard device match`() {
|
||||||
|
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test1")
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns """[ { "model": "test*", "link": "test.com", "showConditionCode": "never", "localePercent": "*:500000", "version": 1 } ]"""
|
||||||
|
DeviceSpecificNotificationConfig.currentConfig assertIs DeviceSpecificNotificationConfig.Config(model = "test*", link = "test.com", showConditionCode = "never", localePercent = "*:500000", version = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `complex wildcard device match`() {
|
||||||
|
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test-1")
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{ "model": "*", "showConditionCode": "always", "localePercent": "*:10000", "version": 1 },
|
||||||
|
{ "model": "test1", "showConditionCode": "has-slow-notifications", "localePercent": "*:20000", "version": 2 },
|
||||||
|
{ "model": "test-", "showConditionCode": "never", "localePercent": "*:30000", "version": 3 }
|
||||||
|
]
|
||||||
|
""".trimMargin()
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config(model = "*", showConditionCode = "always", localePercent = "*:10000", version = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no device match`() {
|
||||||
|
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "bad")
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns """[ { "model": "test", "link": "test.com", "showConditionCode": "always", "localePercent": "*:500000", "version": 1 } ]"""
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default fields is zero percent`() {
|
||||||
|
ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "test")
|
||||||
|
every { RemoteConfig.deviceSpecificNotificationConfig } returns """[ { "model": "test" } ]"""
|
||||||
|
DeviceSpecificNotificationConfig.computeConfig() assertIs DeviceSpecificNotificationConfig.Config(model = "test", localePercent = "*", version = 0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,7 +41,8 @@ class RemoteConfig_StaticValuesTest {
|
||||||
"debugPendingDiskValues",
|
"debugPendingDiskValues",
|
||||||
"CRASH_PROMPT_CONFIG",
|
"CRASH_PROMPT_CONFIG",
|
||||||
"PROMPT_BATTERY_SAVER",
|
"PROMPT_BATTERY_SAVER",
|
||||||
"PROMPT_FOR_NOTIFICATION_LOGS"
|
"PROMPT_FOR_NOTIFICATION_LOGS",
|
||||||
|
"DEVICE_SPECIFIC_NOTIFICATION_CONFIG"
|
||||||
)
|
)
|
||||||
|
|
||||||
val publicVals: List<KProperty1<*, *>> = RemoteConfig::class.memberProperties
|
val publicVals: List<KProperty1<*, *>> = RemoteConfig::class.memberProperties
|
||||||
|
|
Loading…
Add table
Reference in a new issue