Skip to content

Commit 8dc438d

Browse files
committed
AppCleaner: Detect app-lock interference during ACS cache cleaning
Add SettingsInterferenceDetector + AutomationInterferenceException so that when a third-party app (e.g. Avast App Lock) hijacks the system settings window during accessibility cache clearing, the run aborts immediately with a localized, app-naming error instead of the generic AutomationCompatibilityException after ~4 minutes of timeouts. Detection is wired into the shared windowCheckDefaultSettings (covers all specs + AppControl) plus the custom MIUI/HyperOS/Realme window checks via a new interferenceAware() wrapper. Known lockers abort on first sighting; unknown apps require the same non-system foreign window to dominate for >=1.5s. AutomationInterferenceException extends InvalidSystemStateException so it aborts the whole run and surfaces via HasLocalizedError. Refs #2439
1 parent b68ed13 commit 8dc438d

9 files changed

Lines changed: 498 additions & 50 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package eu.darken.sdmse.automation.core.errors
2+
3+
import eu.darken.sdmse.automation.R
4+
import eu.darken.sdmse.common.ca.toCaString
5+
import eu.darken.sdmse.common.error.HasLocalizedError
6+
import eu.darken.sdmse.common.error.LocalizedError
7+
import eu.darken.sdmse.common.pkgs.Pkg
8+
9+
/**
10+
* Thrown when a foreign app (e.g. an app-locker like Avast App Lock) is holding the system
11+
* settings window instead of the screen we are trying to automate, preventing us from reaching
12+
* the target UI.
13+
*
14+
* Subclasses [InvalidSystemStateException] so the whole automation run is aborted immediately
15+
* (instead of grinding through retries until the generic compatibility/timeout error) and tells
16+
* the user which app to adjust.
17+
*/
18+
class AutomationInterferenceException(
19+
val blockerPkg: Pkg.Id,
20+
val blockerLabel: String?,
21+
) : InvalidSystemStateException(
22+
"System settings window is being blocked by $blockerPkg (${blockerLabel ?: "?"})"
23+
), HasLocalizedError {
24+
25+
private val displayName: String = blockerLabel ?: blockerPkg.name
26+
27+
override fun getLocalizedError() = LocalizedError(
28+
throwable = this,
29+
label = R.string.automation_error_interference_title.toCaString(),
30+
description = R.string.automation_error_interference_body.toCaString(displayName),
31+
)
32+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package eu.darken.sdmse.automation.core.specs
2+
3+
import android.os.SystemClock
4+
import eu.darken.sdmse.automation.core.common.ACSNodeInfo
5+
import eu.darken.sdmse.automation.core.errors.AutomationInterferenceException
6+
import eu.darken.sdmse.common.debug.logging.Logging.Priority.WARN
7+
import eu.darken.sdmse.common.debug.logging.log
8+
import eu.darken.sdmse.common.debug.logging.logTag
9+
import eu.darken.sdmse.common.pkgs.Pkg
10+
import eu.darken.sdmse.common.pkgs.toPkgId
11+
12+
/**
13+
* Detects when a foreign app (e.g. an app-locker like Avast App Lock) is holding the active
14+
* window instead of the system settings screen we are trying to automate. Without this, such
15+
* interference only surfaces as a generic timeout/compatibility error after minutes of retries.
16+
*
17+
* One instance lives for the lifetime of a single window-check (i.e. one step), so its sightings
18+
* accumulate across the stepper's inner retry loop. Benign windows reset the tracker.
19+
*
20+
* Two confidence levels:
21+
* - [knownBlockers]: high confidence, abort on the first sighting (even if it is also the target).
22+
* - generic: abort only once the same non-system foreign app has dominated the window for
23+
* [PERSIST_THRESHOLD_MS], so transient windows during the settings launch don't trip it.
24+
*/
25+
class SettingsInterferenceDetector(
26+
private val expectedPkgs: Set<Pkg.Id>,
27+
private val targetPkg: Pkg.Id,
28+
private val knownBlockers: Set<Pkg.Id> = KNOWN_INTERFERENCE_PKGS,
29+
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
30+
private val resolveLabel: suspend (Pkg.Id) -> String?,
31+
private val isSystemApp: suspend (Pkg.Id) -> Boolean,
32+
) {
33+
34+
private var currentForeign: Pkg.Id? = null
35+
private var firstSeenAt: Long = 0L
36+
private val systemAppCache = mutableMapOf<Pkg.Id, Boolean>()
37+
38+
/**
39+
* Inspect the current window [root]. Throws [AutomationInterferenceException] once we are
40+
* confident a foreign app is blocking the settings screen. Safe to call on every observed
41+
* root: a matching/benign window just resets the persistence tracker.
42+
*/
43+
suspend fun evaluate(root: ACSNodeInfo, ownPkg: String) {
44+
// ACSNodeInfo.pkgId is non-null and maps a missing name to Pkg.Id("null"), so extract manually.
45+
val rootPkg = root.packageName?.toString()?.takeIf { it.isNotBlank() }?.toPkgId()
46+
if (rootPkg == null) {
47+
// Unknown/transient window - don't reset, just wait for the next observation.
48+
return
49+
}
50+
51+
// The settings screen (or our own overlay) is up - any prior foreign sighting is stale.
52+
if (rootPkg in expectedPkgs || rootPkg.name == ownPkg) {
53+
reset()
54+
return
55+
}
56+
57+
// Known blockers are high-confidence: fire immediately, even when it is also the target app.
58+
if (rootPkg in knownBlockers) {
59+
log(TAG, WARN) { "Known interfering app is holding the window: $rootPkg" }
60+
throw AutomationInterferenceException(rootPkg, resolveLabel(rootPkg))
61+
}
62+
63+
// The app currently being cleaned may briefly own the window - that's expected.
64+
if (rootPkg == targetPkg) {
65+
reset()
66+
return
67+
}
68+
69+
// System apps (launcher, SystemUI, permission controller, ...) are benign.
70+
val system = systemAppCache[rootPkg] ?: isSystemApp(rootPkg).also { systemAppCache[rootPkg] = it }
71+
if (system) {
72+
reset()
73+
return
74+
}
75+
76+
// Generic detection: require the same foreign app to dominate the window for a while.
77+
val now = nowMs()
78+
if (rootPkg != currentForeign) {
79+
currentForeign = rootPkg
80+
firstSeenAt = now
81+
return
82+
}
83+
if (now - firstSeenAt >= PERSIST_THRESHOLD_MS) {
84+
log(TAG, WARN) { "Foreign app is persistently blocking the window: $rootPkg" }
85+
throw AutomationInterferenceException(rootPkg, resolveLabel(rootPkg))
86+
}
87+
}
88+
89+
private fun reset() {
90+
currentForeign = null
91+
firstSeenAt = 0L
92+
}
93+
94+
companion object {
95+
private val TAG = logTag("Automation", "InterferenceDetector")
96+
97+
private const val PERSIST_THRESHOLD_MS = 1500L
98+
99+
/**
100+
* Apps known to lock or overlay the system settings screen. The generic detector covers
101+
* unlisted ones too; this list mainly lets us abort instantly with higher confidence.
102+
*/
103+
val KNOWN_INTERFERENCE_PKGS: Set<Pkg.Id> = setOf(
104+
"com.avast.android.mobilesecurity", // Avast Mobile Security (App Lock) - originally reported
105+
"com.antivirus", // AVG AntiVirus (App Lock)
106+
"com.symantec.mobilesecurity", // Norton 360 / Mobile Security
107+
"com.symantec.applock", // Norton App Lock
108+
"com.wsandroid.suite", // McAfee Security
109+
"com.kms.free", // Kaspersky Mobile / Internet Security
110+
"com.bitdefender.security", // Bitdefender Mobile Security
111+
"com.domobile.applockwatcher", // AppLock (DoMobile)
112+
"com.domobile.applock", // AppLock (DoMobile, legacy)
113+
"com.sp.protector.free", // Smart AppLock
114+
).map { it.toPkgId() }.toSet()
115+
}
116+
}

app-common-automation/src/main/java/eu/darken/sdmse/automation/core/specs/SpecGeneratorExtensions.kt

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package eu.darken.sdmse.automation.core.specs
44

55
import android.content.Intent
6+
import android.content.pm.ApplicationInfo
67
import android.content.pm.PackageManager
78
import android.content.res.Configuration
89
import android.content.res.Resources
@@ -79,13 +80,58 @@ fun SpecGenerator.windowCheckDefaultSettings(
7980
windowPkgId: Pkg.Id,
8081
ipcFunnel: IPCFunnel,
8182
pkgInfo: Installed
82-
): suspend StepContext.() -> ACSNodeInfo = {
83-
if (stepAttempts >= 1 && pkgInfo.hasNoSettings) {
84-
throw NoSettingsWindowException("${pkgInfo.packageName} has no settings window.")
85-
}
86-
windowCheck { _, root ->
83+
): suspend StepContext.() -> ACSNodeInfo {
84+
val condition = interferenceAware(
85+
expectedPkgs = setOf(windowPkgId),
86+
targetPkg = pkgInfo.id,
87+
ipcFunnel = ipcFunnel,
88+
) { _, root ->
8789
root.pkgId == windowPkgId && checkIdentifiers(ipcFunnel, pkgInfo)(root)
88-
}()
90+
}
91+
return {
92+
if (stepAttempts >= 1 && pkgInfo.hasNoSettings) {
93+
throw NoSettingsWindowException("${pkgInfo.packageName} has no settings window.")
94+
}
95+
windowCheck(condition)()
96+
}
97+
}
98+
99+
/**
100+
* Wraps a window-check [condition] with [SettingsInterferenceDetector]: whenever the condition
101+
* does NOT match, the current window is checked for a foreign app (e.g. an app-locker) blocking
102+
* the [expectedPkgs] settings screen. One detector is created per call, so it accumulates sightings
103+
* across the stepper's inner retry loop. [targetPkg] is the app being processed.
104+
*/
105+
fun SpecGenerator.interferenceAware(
106+
expectedPkgs: Set<Pkg.Id>,
107+
targetPkg: Pkg.Id,
108+
ipcFunnel: IPCFunnel,
109+
condition: suspend StepContext.(event: AutomationEvent?, root: ACSNodeInfo) -> Boolean,
110+
): suspend StepContext.(event: AutomationEvent?, root: ACSNodeInfo) -> Boolean {
111+
val detector = SettingsInterferenceDetector(
112+
expectedPkgs = expectedPkgs,
113+
targetPkg = targetPkg,
114+
resolveLabel = { pkg -> ipcFunnel.use { packageManager.getLabel2(pkg) } },
115+
isSystemApp = { pkg ->
116+
ipcFunnel.use {
117+
try {
118+
val info = packageManager.getApplicationInfo(pkg.name, 0)
119+
info.flags and ApplicationInfo.FLAG_SYSTEM != 0 ||
120+
info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
121+
} catch (_: PackageManager.NameNotFoundException) {
122+
// Unknown app - treat as non-system so generic detection can still fire.
123+
false
124+
}
125+
}
126+
},
127+
)
128+
return { event, root ->
129+
// Detection runs before the predicate so an interfering window is caught even if the
130+
// predicate would otherwise throw (e.g. NoSettingsWindowException). On a matching or benign
131+
// root the detector just resets, so evaluating it first is safe.
132+
detector.evaluate(root, androidContext.packageName)
133+
condition(event, root)
134+
}
89135
}
90136

91137
suspend fun SpecGenerator.checkIdentifiers(

app-common-automation/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
<string name="automation_error_scheduler_body">A scheduled task tried to use the accessibility service, but it was unavailable. If you don\'t need this, disable the \"Accessibility service\" option in the scheduler settings.</string>
2222
<string name="automation_error_no_settings_title">Settings screen unavailable</string>
2323
<string name="automation_error_no_settings_body">This system app has no settings screen. Cache clearing via accessibility service is not possible. Exclude it to speed up cache clearing.</string>
24+
<string name="automation_error_interference_title">Settings screen blocked</string>
25+
<string name="automation_error_interference_body">Another app (%1$s) is blocking the system settings screen, so SD Maid can\'t continue. Disable that app\'s app-lock or screen protection for the system settings, then try again.</string>
2426
<string name="automation_loading">Preparing automation via accessibility service</string>
2527
<string name="automation_progress_find_ok_confirmation">Trying to confirm previous action (keywords: %s)</string>
2628
</resources>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package eu.darken.sdmse.automation.core.specs
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import eu.darken.sdmse.automation.core.AutomationEvent
6+
import eu.darken.sdmse.automation.core.AutomationHost
7+
import eu.darken.sdmse.automation.core.common.ACSNodeInfo
8+
import eu.darken.sdmse.automation.core.common.pkgId
9+
import eu.darken.sdmse.automation.core.common.stepper.StepContext
10+
import eu.darken.sdmse.automation.core.errors.AutomationInterferenceException
11+
import eu.darken.sdmse.common.funnel.IPCFunnel
12+
import eu.darken.sdmse.common.pkgs.features.Installed
13+
import eu.darken.sdmse.common.pkgs.toPkgId
14+
import eu.darken.sdmse.common.progress.Progress
15+
import io.kotest.assertions.throwables.shouldThrow
16+
import io.kotest.matchers.shouldBe
17+
import io.mockk.coEvery
18+
import io.mockk.every
19+
import io.mockk.mockk
20+
import kotlinx.coroutines.flow.Flow
21+
import kotlinx.coroutines.flow.emptyFlow
22+
import kotlinx.coroutines.test.runTest
23+
import org.junit.Test
24+
import org.junit.runner.RunWith
25+
import org.robolectric.RobolectricTestRunner
26+
import org.robolectric.annotation.Config
27+
import testhelpers.BaseTest
28+
import testhelpers.TestApplication
29+
import testhelpers.coroutine.TestDispatcherProvider
30+
31+
/**
32+
* Exercises the real wiring that the pure detector test skips:
33+
* windowCheckDefaultSettings / interferenceAware -> windowCheck flow -> SettingsInterferenceDetector,
34+
* driven by a fake [AutomationHost]. Pure-logic / timing cases live in SettingsInterferenceDetectorTest.
35+
*/
36+
@RunWith(RobolectricTestRunner::class)
37+
@Config(sdk = [33], application = TestApplication::class)
38+
class InterferenceDetectionWiringTest : BaseTest() {
39+
40+
private val appContext: Context = ApplicationProvider.getApplicationContext()
41+
private val ipcFunnel = IPCFunnel(appContext, TestDispatcherProvider())
42+
43+
private val settingsPkg = "com.android.settings".toPkgId()
44+
private val targetPkg = "com.example.target".toPkgId()
45+
private val avastPkg = "com.avast.android.mobilesecurity".toPkgId()
46+
47+
private val specGen = object : SpecGenerator {
48+
override val tag = "Test"
49+
override suspend fun isResponsible(pkg: Installed) = false
50+
}
51+
52+
private fun rootOf(pkg: String): ACSNodeInfo = mockk(relaxed = true) {
53+
every { packageName } returns pkg
54+
}
55+
56+
private fun hostWith(root: ACSNodeInfo, eventFlow: Flow<AutomationEvent> = emptyFlow()): AutomationHost =
57+
mockk(relaxed = true) {
58+
every { events } returns eventFlow
59+
coEvery { windowRoot() } returns root
60+
}
61+
62+
private fun stepContextWith(host: AutomationHost): StepContext {
63+
val context = object : AutomationExplorer.Context {
64+
override val host: AutomationHost = host
65+
override val androidContext: Context = appContext
66+
override val progress: Flow<Progress.Data?> = emptyFlow()
67+
override fun updateProgress(update: (Progress.Data?) -> Progress.Data?) {}
68+
}
69+
return StepContext(hostContext = context, tag = "Test", stepAttempts = 0)
70+
}
71+
72+
private fun pkgInfo(): Installed = mockk(relaxed = true) {
73+
every { id } returns targetPkg
74+
every { packageName } returns targetPkg.name
75+
every { hasNoSettings } returns false
76+
}
77+
78+
@Test
79+
fun `windowCheckDefaultSettings aborts when a known locker holds the settings window`() = runTest {
80+
val host = hostWith(rootOf(avastPkg.name))
81+
val stepContext = stepContextWith(host)
82+
val check = with(specGen) { windowCheckDefaultSettings(settingsPkg, ipcFunnel, pkgInfo()) }
83+
84+
shouldThrow<AutomationInterferenceException> {
85+
check(stepContext)
86+
}.blockerPkg shouldBe avastPkg
87+
}
88+
89+
@Test
90+
fun `interferenceAware passes the expected settings window through unharmed`() = runTest {
91+
val root = rootOf(settingsPkg.name)
92+
val stepContext = stepContextWith(hostWith(root))
93+
val condition = with(specGen) {
94+
interferenceAware(setOf(settingsPkg), targetPkg, ipcFunnel) { _, r -> r.pkgId == settingsPkg }
95+
}
96+
val check = with(specGen) { windowCheck(condition) }
97+
98+
check(stepContext) shouldBe root
99+
}
100+
}

0 commit comments

Comments
 (0)