feat(downloads): warn about Android background restrictions
This commit is contained in:
@@ -417,6 +417,36 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// BackgroundWorkDiagnostics routes users to background_downloader's private
|
||||
// notification channel. Fail the build if an upstream ref changes that ID.
|
||||
val verifyBackgroundDownloaderNotificationChannel = tasks.register("verifyBackgroundDownloaderNotificationChannel") {
|
||||
val expectedChannelId = "background_downloader"
|
||||
val downloaderProject = rootProject.findProject(":background_downloader")
|
||||
val notificationsSource = downloaderProject?.projectDir?.resolve(
|
||||
"src/main/kotlin/com/bbflight/background_downloader/Notifications.kt"
|
||||
)
|
||||
notificationsSource?.let(inputs::file)
|
||||
doLast {
|
||||
if (notificationsSource == null) {
|
||||
logger.lifecycle("Skipping downloader channel verification: Flutter plugin project is not configured")
|
||||
return@doLast
|
||||
}
|
||||
val actualChannelId = Regex(
|
||||
"""private const val notificationChannelId\s*=\s*"([^"]+)""""
|
||||
).find(notificationsSource.readText())?.groupValues?.get(1)
|
||||
?: throw GradleException("Could not locate background_downloader's notification channel ID")
|
||||
if (actualChannelId != expectedChannelId) {
|
||||
throw GradleException(
|
||||
"background_downloader channel ID changed from $expectedChannelId to $actualChannelId; " +
|
||||
"update BackgroundWorkDiagnostics and its tests"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(verifyBackgroundDownloaderNotificationChannel)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Raw per-signal snapshot of everything the OS is willing to tell us about
|
||||
* whether our own background work will be allowed to run.
|
||||
*
|
||||
* Every field is nullable: a null means "this API level cannot answer", not
|
||||
* "unrestricted". [BackgroundWorkClassifier] treats unknown as OK, because a
|
||||
* warning we cannot substantiate is worse than no warning at all.
|
||||
*/
|
||||
internal data class BackgroundWorkSignals(
|
||||
val sdkInt: Int? = null,
|
||||
val backgroundRestricted: Boolean? = null,
|
||||
val standbyBucket: Int? = null,
|
||||
val notificationsEnabled: Boolean? = null,
|
||||
val downloadChannelBlocked: Boolean? = null,
|
||||
val dataSaverRestricted: Boolean? = null,
|
||||
val ignoringBatteryOptimizations: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Turns [BackgroundWorkSignals] into a verdict plus ordered reasons.
|
||||
*
|
||||
* Deliberately pure and free of `Build.VERSION` reads so the whole decision
|
||||
* table is exercisable from plain JVM unit tests.
|
||||
*/
|
||||
internal object BackgroundWorkClassifier {
|
||||
const val VERDICT_OK = "ok"
|
||||
const val VERDICT_DEGRADED = "degraded"
|
||||
const val VERDICT_BLOCKED = "blocked"
|
||||
|
||||
const val REASON_BACKGROUND_RESTRICTED = "background_restricted"
|
||||
const val REASON_STANDBY_RESTRICTED = "standby_restricted"
|
||||
const val REASON_DOWNLOAD_CHANNEL_BLOCKED = "download_channel_blocked"
|
||||
const val REASON_NOTIFICATIONS_DISABLED = "notifications_disabled"
|
||||
const val REASON_DATA_SAVER = "data_saver"
|
||||
|
||||
/**
|
||||
* `STANDBY_BUCKET_RESTRICTED` (API 30) and `STANDBY_BUCKET_NEVER`. Declared
|
||||
* literally so the classifier stays compilable and testable below those API
|
||||
* levels. `RARE` (40) is intentionally absent — it is reached by ordinary
|
||||
* disuse and says nothing about a user-imposed restriction, so warning on it
|
||||
* would fire on healthy devices.
|
||||
*/
|
||||
const val BUCKET_RESTRICTED = 45
|
||||
const val BUCKET_NEVER = 50
|
||||
|
||||
private val blockingReasons = setOf(
|
||||
REASON_BACKGROUND_RESTRICTED,
|
||||
REASON_STANDBY_RESTRICTED
|
||||
)
|
||||
|
||||
/** Ordered most- to least-actionable; the UI leads with the first entry. */
|
||||
fun reasons(signals: BackgroundWorkSignals): List<String> = buildList {
|
||||
if (signals.backgroundRestricted == true) add(REASON_BACKGROUND_RESTRICTED)
|
||||
if (signals.standbyBucket != null && isRestrictedBucket(signals.standbyBucket)) add(REASON_STANDBY_RESTRICTED)
|
||||
// App-wide denial is more fundamental than a muted channel and routes to
|
||||
// a screen that also exists before the channel has been created.
|
||||
if (signals.notificationsEnabled == false) {
|
||||
add(REASON_NOTIFICATIONS_DISABLED)
|
||||
} else if (signals.downloadChannelBlocked == true) {
|
||||
add(REASON_DOWNLOAD_CHANNEL_BLOCKED)
|
||||
}
|
||||
if (signals.dataSaverRestricted == true) add(REASON_DATA_SAVER)
|
||||
}
|
||||
|
||||
fun verdict(signals: BackgroundWorkSignals, reasons: List<String> = reasons(signals)): String = when {
|
||||
reasons.any(blockingReasons::contains) -> VERDICT_BLOCKED
|
||||
signals.sdkInt != null &&
|
||||
signals.sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
signals.notificationsEnabled == false -> VERDICT_BLOCKED
|
||||
reasons.isNotEmpty() -> VERDICT_DEGRADED
|
||||
else -> VERDICT_OK
|
||||
}
|
||||
|
||||
fun isRestrictedBucket(bucket: Int): Boolean = bucket == BUCKET_RESTRICTED || bucket == BUCKET_NEVER
|
||||
|
||||
fun toMap(signals: BackgroundWorkSignals): Map<String, Any?> {
|
||||
val reasons = reasons(signals)
|
||||
return mapOf(
|
||||
"verdict" to verdict(signals, reasons),
|
||||
"reasons" to reasons,
|
||||
"sdkInt" to signals.sdkInt,
|
||||
"backgroundRestricted" to signals.backgroundRestricted,
|
||||
"standbyBucket" to signals.standbyBucket,
|
||||
"notificationsEnabled" to signals.notificationsEnabled,
|
||||
"downloadChannelBlocked" to signals.downloadChannelBlocked,
|
||||
"dataSaverRestricted" to signals.dataSaverRestricted,
|
||||
"ignoringBatteryOptimizations" to signals.ignoringBatteryOptimizations
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings screen a remedy button can send the user to. */
|
||||
internal enum class BackgroundSettingsTarget(val id: String) {
|
||||
APP_DETAILS("app_details"),
|
||||
APP_NOTIFICATIONS("app_notifications"),
|
||||
NOTIFICATION_CHANNEL("notification_channel");
|
||||
|
||||
companion object {
|
||||
fun fromId(raw: String?): BackgroundSettingsTarget? = entries.firstOrNull { it.id == raw }
|
||||
}
|
||||
}
|
||||
|
||||
/** Declarative intent description, kept Intent-free so it unit-tests on the JVM. */
|
||||
internal data class SettingsIntentSpec(
|
||||
val action: String,
|
||||
val data: String? = null,
|
||||
val stringExtras: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
* Ordered candidate intents per target, each falling back to the app details
|
||||
* page — the one Settings screen that exists on every Android build and that
|
||||
* reaches Battery → Unrestricted on One UI, Pixel, and AOSP alike.
|
||||
*
|
||||
* Samsung's own "Background usage limits" screen lives in an unexported
|
||||
* `com.samsung.android.lool` activity that moves between One UI releases, so
|
||||
* it is deliberately not targeted; the user gets written directions instead.
|
||||
*/
|
||||
internal object BackgroundSettingsIntents {
|
||||
fun specsFor(
|
||||
target: BackgroundSettingsTarget,
|
||||
packageName: String,
|
||||
sdkInt: Int
|
||||
): List<SettingsIntentSpec> {
|
||||
val appDetails = SettingsIntentSpec(
|
||||
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
data = "package:$packageName"
|
||||
)
|
||||
if (sdkInt < Build.VERSION_CODES.O) return listOf(appDetails)
|
||||
return when (target) {
|
||||
BackgroundSettingsTarget.APP_DETAILS -> listOf(appDetails)
|
||||
BackgroundSettingsTarget.APP_NOTIFICATIONS -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
BackgroundSettingsTarget.NOTIFICATION_CHANNEL -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(
|
||||
Settings.EXTRA_APP_PACKAGE to packageName,
|
||||
Settings.EXTRA_CHANNEL_ID to BackgroundWorkDiagnostics.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
),
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OS-visible reasons background downloads may be blocked.
|
||||
*
|
||||
* Every read is for our own package and needs no permission. Notably absent is
|
||||
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: it is a Play-policy-restricted
|
||||
* permission whose qualifying use cases do not include media downloading.
|
||||
* Whitelist state is reported for support diagnostics only.
|
||||
*/
|
||||
internal object BackgroundWorkDiagnostics {
|
||||
/** `background_downloader`'s channel id (`Notifications.kt`, `nId`). */
|
||||
const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "background_downloader"
|
||||
|
||||
fun read(context: Context): BackgroundWorkSignals = BackgroundWorkSignals(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
backgroundRestricted = readBackgroundRestricted(context),
|
||||
standbyBucket = readStandbyBucket(context),
|
||||
notificationsEnabled = readNotificationsEnabled(context),
|
||||
downloadChannelBlocked = readDownloadChannelBlocked(context),
|
||||
dataSaverRestricted = readDataSaverRestricted(context),
|
||||
ignoringBatteryOptimizations = readIgnoringBatteryOptimizations(context)
|
||||
)
|
||||
|
||||
private fun readBackgroundRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).isBackgroundRestricted
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readStandbyBucket(context: Context): Int? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readNotificationsEnabled(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Null until the plugin has created its channel (first download), which is
|
||||
* correct — there is nothing to warn about before then.
|
||||
*/
|
||||
private fun readDownloadChannelBlocked(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
|
||||
return runCatching {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = manager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) ?: return null
|
||||
channel.importance == NotificationManager.IMPORTANCE_NONE
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readDataSaverRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null
|
||||
return runCatching {
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivity.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readIgnoringBatteryOptimizations(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Launches the first candidate Settings screen that resolves. Returns false
|
||||
* when the device has none of them — some TV and Fire OS builds ship without
|
||||
* a battery settings activity entirely.
|
||||
*/
|
||||
fun openSettings(context: Context, target: BackgroundSettingsTarget): Boolean {
|
||||
for (spec in BackgroundSettingsIntents.specsFor(target, context.packageName, Build.VERSION.SDK_INT)) {
|
||||
val launched = runCatching {
|
||||
val intent = Intent(spec.action).apply {
|
||||
spec.data?.let { data = Uri.parse(it) }
|
||||
spec.stringExtras.forEach { (key, value) -> putExtra(key, value) }
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (launched) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -573,6 +573,13 @@ class MainActivity : FlutterActivity() {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getBackgroundWorkSignals" -> result.success(
|
||||
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
|
||||
)
|
||||
"openBackgroundSettings" -> {
|
||||
val target = BackgroundSettingsTarget.fromId(call.arguments as? String)
|
||||
result.success(target != null && BackgroundWorkDiagnostics.openSettings(this, target))
|
||||
}
|
||||
"getPreviousExit" -> handlePreviousExit(result)
|
||||
"setStartupPhase" -> queueStartupPhase(call.arguments as? String, result)
|
||||
"setRuntimeUiState" -> {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BackgroundWorkDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun unknownSignalsAreTreatedAsHealthy() {
|
||||
val signals = BackgroundWorkSignals()
|
||||
|
||||
assertEquals(emptyList<String>(), BackgroundWorkClassifier.reasons(signals))
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_OK, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unrestrictedDeviceIsHealthy() {
|
||||
val signals = BackgroundWorkSignals(
|
||||
backgroundRestricted = false,
|
||||
standbyBucket = 10,
|
||||
notificationsEnabled = true,
|
||||
downloadChannelBlocked = false,
|
||||
dataSaverRestricted = false,
|
||||
ignoringBatteryOptimizations = false
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), BackgroundWorkClassifier.reasons(signals))
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_OK, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingBatteryWhitelistAloneNeverWarns() {
|
||||
// The overwhelming majority of healthy installs are not whitelisted; a
|
||||
// warning keyed off this would fire on essentially every device.
|
||||
val signals = BackgroundWorkSignals(
|
||||
backgroundRestricted = false,
|
||||
standbyBucket = 20,
|
||||
notificationsEnabled = true,
|
||||
ignoringBatteryOptimizations = false
|
||||
)
|
||||
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_OK, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rareBucketIsNotRestricted() {
|
||||
// RARE is reached by ordinary disuse and is not a user-imposed limit.
|
||||
assertFalse(BackgroundWorkClassifier.isRestrictedBucket(10))
|
||||
assertFalse(BackgroundWorkClassifier.isRestrictedBucket(20))
|
||||
assertFalse(BackgroundWorkClassifier.isRestrictedBucket(30))
|
||||
assertFalse(BackgroundWorkClassifier.isRestrictedBucket(40))
|
||||
|
||||
val signals = BackgroundWorkSignals(standbyBucket = 40, notificationsEnabled = true)
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_OK, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restrictedAndNeverBucketsBlock() {
|
||||
assertTrue(BackgroundWorkClassifier.isRestrictedBucket(BackgroundWorkClassifier.BUCKET_RESTRICTED))
|
||||
assertTrue(BackgroundWorkClassifier.isRestrictedBucket(BackgroundWorkClassifier.BUCKET_NEVER))
|
||||
|
||||
for (bucket in listOf(BackgroundWorkClassifier.BUCKET_RESTRICTED, BackgroundWorkClassifier.BUCKET_NEVER)) {
|
||||
val signals = BackgroundWorkSignals(standbyBucket = bucket, notificationsEnabled = true)
|
||||
assertEquals(
|
||||
listOf(BackgroundWorkClassifier.REASON_STANDBY_RESTRICTED),
|
||||
BackgroundWorkClassifier.reasons(signals)
|
||||
)
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_BLOCKED, verdictFor(signals))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backgroundRestrictionBlocksAndLeadsTheReasons() {
|
||||
// The Samsung "Background usage limits" case that motivated this: both the
|
||||
// AOSP restriction flag and the standby bucket flip together.
|
||||
val signals = BackgroundWorkSignals(
|
||||
backgroundRestricted = true,
|
||||
standbyBucket = BackgroundWorkClassifier.BUCKET_RESTRICTED,
|
||||
notificationsEnabled = true,
|
||||
downloadChannelBlocked = false
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
BackgroundWorkClassifier.REASON_BACKGROUND_RESTRICTED,
|
||||
BackgroundWorkClassifier.REASON_STANDBY_RESTRICTED
|
||||
),
|
||||
BackgroundWorkClassifier.reasons(signals)
|
||||
)
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_BLOCKED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledNotificationsBlockOnAndroid13AndNewer() {
|
||||
val signals = BackgroundWorkSignals(
|
||||
sdkInt = 33,
|
||||
backgroundRestricted = false,
|
||||
notificationsEnabled = false
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(BackgroundWorkClassifier.REASON_NOTIFICATIONS_DISABLED),
|
||||
BackgroundWorkClassifier.reasons(signals)
|
||||
)
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_BLOCKED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledNotificationsOnlyDegradeBeforeAndroid13() {
|
||||
val signals = BackgroundWorkSignals(
|
||||
sdkInt = 32,
|
||||
backgroundRestricted = false,
|
||||
notificationsEnabled = false
|
||||
)
|
||||
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_DEGRADED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutedDownloadChannelIsDegraded() {
|
||||
val signals = BackgroundWorkSignals(notificationsEnabled = true, downloadChannelBlocked = true)
|
||||
|
||||
assertEquals(
|
||||
listOf(BackgroundWorkClassifier.REASON_DOWNLOAD_CHANNEL_BLOCKED),
|
||||
BackgroundWorkClassifier.reasons(signals)
|
||||
)
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_DEGRADED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appWideNotificationDenialSupersedesTheChannelReason() {
|
||||
val signals = BackgroundWorkSignals(sdkInt = 33, notificationsEnabled = false, downloadChannelBlocked = true)
|
||||
|
||||
assertEquals(
|
||||
listOf(BackgroundWorkClassifier.REASON_NOTIFICATIONS_DISABLED),
|
||||
BackgroundWorkClassifier.reasons(signals)
|
||||
)
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_BLOCKED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentChannelIsNotAFault() {
|
||||
// The plugin creates its channel on first download; before then there is
|
||||
// nothing to warn about.
|
||||
val signals = BackgroundWorkSignals(notificationsEnabled = true, downloadChannelBlocked = null)
|
||||
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_OK, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dataSaverOnlyDegrades() {
|
||||
val signals = BackgroundWorkSignals(
|
||||
backgroundRestricted = false,
|
||||
standbyBucket = 20,
|
||||
notificationsEnabled = true,
|
||||
dataSaverRestricted = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(BackgroundWorkClassifier.REASON_DATA_SAVER), BackgroundWorkClassifier.reasons(signals))
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_DEGRADED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingReasonOutranksDataSaver() {
|
||||
val signals = BackgroundWorkSignals(backgroundRestricted = true, dataSaverRestricted = true)
|
||||
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_BLOCKED, verdictFor(signals))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapCarriesVerdictReasonsAndRawSignals() {
|
||||
val signals = BackgroundWorkSignals(
|
||||
sdkInt = 33,
|
||||
backgroundRestricted = true,
|
||||
standbyBucket = BackgroundWorkClassifier.BUCKET_RESTRICTED,
|
||||
notificationsEnabled = false,
|
||||
downloadChannelBlocked = true,
|
||||
dataSaverRestricted = false,
|
||||
ignoringBatteryOptimizations = false
|
||||
)
|
||||
|
||||
val map = BackgroundWorkClassifier.toMap(signals)
|
||||
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_BLOCKED, map["verdict"])
|
||||
assertEquals(
|
||||
listOf(
|
||||
BackgroundWorkClassifier.REASON_BACKGROUND_RESTRICTED,
|
||||
BackgroundWorkClassifier.REASON_STANDBY_RESTRICTED,
|
||||
BackgroundWorkClassifier.REASON_NOTIFICATIONS_DISABLED
|
||||
),
|
||||
map["reasons"]
|
||||
)
|
||||
assertEquals(33, map["sdkInt"])
|
||||
assertEquals(true, map["backgroundRestricted"])
|
||||
assertEquals(BackgroundWorkClassifier.BUCKET_RESTRICTED, map["standbyBucket"])
|
||||
assertEquals(false, map["notificationsEnabled"])
|
||||
assertEquals(true, map["downloadChannelBlocked"])
|
||||
assertEquals(false, map["dataSaverRestricted"])
|
||||
assertEquals(false, map["ignoringBatteryOptimizations"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownSignalsSurviveAsNullsInTheMap() {
|
||||
val map = BackgroundWorkClassifier.toMap(BackgroundWorkSignals())
|
||||
|
||||
assertEquals(BackgroundWorkClassifier.VERDICT_OK, map["verdict"])
|
||||
assertTrue(map.containsKey("standbyBucket"))
|
||||
assertEquals(null, map["standbyBucket"])
|
||||
assertEquals(null, map["backgroundRestricted"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsTargetsParseFromTheirWireIds() {
|
||||
assertEquals(BackgroundSettingsTarget.APP_DETAILS, BackgroundSettingsTarget.fromId("app_details"))
|
||||
assertEquals(
|
||||
BackgroundSettingsTarget.APP_NOTIFICATIONS,
|
||||
BackgroundSettingsTarget.fromId("app_notifications")
|
||||
)
|
||||
assertEquals(
|
||||
BackgroundSettingsTarget.NOTIFICATION_CHANNEL,
|
||||
BackgroundSettingsTarget.fromId("notification_channel")
|
||||
)
|
||||
assertEquals(null, BackgroundSettingsTarget.fromId("nonsense"))
|
||||
assertEquals(null, BackgroundSettingsTarget.fromId(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyTargetFallsBackToTheAppDetailsPage() {
|
||||
for (target in BackgroundSettingsTarget.entries) {
|
||||
val specs = BackgroundSettingsIntents.specsFor(target, "com.edde746.plezy", 35)
|
||||
assertTrue(specs.isNotEmpty())
|
||||
val last = specs.last()
|
||||
assertEquals("android.settings.APPLICATION_DETAILS_SETTINGS", last.action)
|
||||
assertEquals("package:com.edde746.plezy", last.data)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationChannelTargetAddressesTheDownloaderChannel() {
|
||||
val first = BackgroundSettingsIntents.specsFor(
|
||||
BackgroundSettingsTarget.NOTIFICATION_CHANNEL,
|
||||
"com.edde746.plezy",
|
||||
35
|
||||
).first()
|
||||
|
||||
assertEquals("android.settings.CHANNEL_NOTIFICATION_SETTINGS", first.action)
|
||||
assertEquals("com.edde746.plezy", first.stringExtras["android.provider.extra.APP_PACKAGE"])
|
||||
assertEquals(
|
||||
BackgroundWorkDiagnostics.DOWNLOAD_NOTIFICATION_CHANNEL_ID,
|
||||
first.stringExtras["android.provider.extra.CHANNEL_ID"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appNotificationTargetDoesNotRequireAnExistingChannel() {
|
||||
val first = BackgroundSettingsIntents.specsFor(
|
||||
BackgroundSettingsTarget.APP_NOTIFICATIONS,
|
||||
"com.edde746.plezy",
|
||||
35
|
||||
).first()
|
||||
|
||||
assertEquals("android.settings.APP_NOTIFICATION_SETTINGS", first.action)
|
||||
assertEquals("com.edde746.plezy", first.stringExtras["android.provider.extra.APP_PACKAGE"])
|
||||
assertEquals(null, first.stringExtras["android.provider.extra.CHANNEL_ID"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preOreoNotificationRemediesFallBackToAppDetails() {
|
||||
for (target in listOf(BackgroundSettingsTarget.APP_NOTIFICATIONS, BackgroundSettingsTarget.NOTIFICATION_CHANNEL)) {
|
||||
val first = BackgroundSettingsIntents.specsFor(target, "com.edde746.plezy", 25).first()
|
||||
assertEquals("android.settings.APPLICATION_DETAILS_SETTINGS", first.action)
|
||||
assertEquals("package:com.edde746.plezy", first.data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verdictFor(signals: BackgroundWorkSignals): String = BackgroundWorkClassifier.verdict(signals)
|
||||
}
|
||||
Reference in New Issue
Block a user