Update device-specific notification support configs.

This commit is contained in:
Michelle Tang 2024-07-09 12:04:09 -04:00 committed by Cody Henthorne
parent 60a0565ba8
commit 9024c19169
14 changed files with 319 additions and 131 deletions

View file

@ -20,6 +20,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.donations.StripeApi;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
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.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@ -112,7 +113,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
switch (state) {
case NONE:
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());
break;
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:

View file

@ -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()
}
}

View file

@ -18,7 +18,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
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.LocalMetrics
import org.thoughtcrime.securesms.util.PowerManagerCompat
@ -35,7 +35,7 @@ class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFrag
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
PromptBatterySaverDialogFragment().apply {
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)
SignalStore.uiHints.lastBatterySaverPrompt = System.currentTimeMillis()

View file

@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
@ -120,7 +121,8 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
messagePrivacy = SignalStore.settings.messageNotificationsPrivacy.toString(),
priority = TextSecurePreferences.getNotificationPriority(AppDependencies.application),
troubleshootNotifications = if (calculateSlowNotifications) {
SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && (SlowNotificationHeuristics.isHavingDelayedNotifications() || SlowNotificationHeuristics.showPreemptively())
(SlowNotificationHeuristics.isBatteryOptimizationsOn() && SlowNotificationHeuristics.isHavingDelayedNotifications()) ||
SlowNotificationHeuristics.showCondition() == DeviceSpecificNotificationConfig.ShowCondition.ALWAYS
} else if (currentState != null) {
currentState.messageNotificationsState.troubleshootNotifications
} else {

View file

@ -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 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 LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
UiHintValues(@NonNull KeyValueStore store) {
super(store);
@ -185,4 +186,18 @@ public class UiHintValues extends SignalStoreValues {
public boolean getHasSeenDeleteSyncEducationSheet() {
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);
}
}

View file

@ -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
*/
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"
@JvmStatic
val currentConfig: Config by lazy { computeConfig() }
/**
* 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 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 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(
@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 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
fun computeConfig(): Config {
val default = Config()
val serialized = RemoteConfig.promptDelayedNotificationConfig
if (serialized.isNullOrBlank()) {
val serialized = RemoteConfig.deviceSpecificNotificationConfig
if (serialized.isBlank()) {
return default
}

View file

@ -132,7 +132,7 @@ object SlowNotificationHeuristics {
* true can most definitely be at fault.
*/
@JvmStatic
fun isPotentiallyCausedByBatteryOptimizations(): Boolean {
fun isBatteryOptimizationsOn(): Boolean {
val applicationContext = AppDependencies.application
if (DeviceProperties.getDataSaverState(applicationContext) == DeviceProperties.DataSaverState.ENABLED) {
return false
@ -143,8 +143,12 @@ object SlowNotificationHeuristics {
return true
}
fun showPreemptively(): Boolean {
return DelayedNotificationConfig.currentConfig.showPreemptively
fun showCondition(): DeviceSpecificNotificationConfig.ShowCondition {
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 {

View file

@ -46,17 +46,29 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte
private fun checkHeuristics(): Single<State> {
return Single.fromCallable {
var state = State.NONE
if (SlowNotificationHeuristics.showPreemptively() || SlowNotificationHeuristics.isHavingDelayedNotifications()) {
if (SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.shouldPromptBatterySaver()) {
state = State.PROMPT_BATTERY_SAVER_DIALOG
} else if (SlowNotificationHeuristics.shouldPromptUserForLogs()) {
state = State.PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS
when (SlowNotificationHeuristics.showCondition()) {
DeviceSpecificNotificationConfig.ShowCondition.ALWAYS -> {
if (SlowNotificationHeuristics.shouldShowDialog()) {
state = State.PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG
}
}
} else if (LogDatabase.getInstance(context).crashes.anyMatch(patterns = CrashConfig.patterns, promptThreshold = System.currentTimeMillis() - 14.days.inWholeMilliseconds)) {
val timeSinceLastPrompt = System.currentTimeMillis() - SignalStore.uiHints.lastCrashPrompt
DeviceSpecificNotificationConfig.ShowCondition.HAS_BATTERY_OPTIMIZATION_ON -> {
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) {
state = State.PROMPT_DEBUGLOGS_FOR_CRASH
if (timeSinceLastPrompt > 1.days.inWholeMilliseconds) {
state = State.PROMPT_DEBUGLOGS_FOR_CRASH
}
}
}
}
@ -66,7 +78,8 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte
enum class State {
NONE,
PROMPT_BATTERY_SAVER_DIALOG,
PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG,
PROMPT_GENERAL_BATTERY_SAVER_DIALOG,
PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS,
PROMPT_DEBUGLOGS_FOR_CRASH
}

View file

@ -8,7 +8,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.signal.core.util.logging.Log;
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 java.util.Arrays;
@ -75,7 +75,11 @@ public final class LocaleRemoteConfig {
}
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());
}
/**

View file

@ -874,10 +874,10 @@ object RemoteConfig {
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(
key = PROMPT_DELAYED_NOTIFICATION_CONFIG,
val deviceSpecificNotificationConfig: String by remoteString(
key = DEVICE_SPECIFIC_NOTIFICATION_CONFIG,
defaultValue = "",
hotSwappable = true
)

View file

@ -1097,6 +1097,15 @@
<!-- 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>
<!-- 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 -->
<string name="PromptBatterySaverBottomSheet__continue">Continue</string>
<!-- Button to dismiss battery saver dialog prompt-->

View file

@ -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 = "*")
}
}

View file

@ -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)
}
}

View file

@ -41,7 +41,8 @@ class RemoteConfig_StaticValuesTest {
"debugPendingDiskValues",
"CRASH_PROMPT_CONFIG",
"PROMPT_BATTERY_SAVER",
"PROMPT_FOR_NOTIFICATION_LOGS"
"PROMPT_FOR_NOTIFICATION_LOGS",
"DEVICE_SPECIFIC_NOTIFICATION_CONFIG"
)
val publicVals: List<KProperty1<*, *>> = RemoteConfig::class.memberProperties