Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package eu.darken.sdmse.appcleaner.core.automation.specs.aosp

import android.graphics.Rect
import dagger.Binds
import dagger.Module
import dagger.Reusable
Expand All @@ -10,13 +11,19 @@ import eu.darken.sdmse.R
import eu.darken.sdmse.appcleaner.core.automation.specs.AppCleanerSpecGenerator
import eu.darken.sdmse.appcleaner.core.automation.specs.StorageEntryFinder
import eu.darken.sdmse.appcleaner.core.automation.specs.clickClearCache
import eu.darken.sdmse.automation.core.common.ACSNodeInfo
import eu.darken.sdmse.automation.core.common.isClickyButton
import eu.darken.sdmse.automation.core.common.isTextView
import eu.darken.sdmse.automation.core.common.stepper.AutomationStep
import eu.darken.sdmse.automation.core.common.stepper.StepContext
import eu.darken.sdmse.automation.core.common.stepper.Stepper
import eu.darken.sdmse.automation.core.common.stepper.clickGestureAtCoords
import eu.darken.sdmse.automation.core.common.stepper.findClickableParent
import eu.darken.sdmse.automation.core.common.stepper.findClickableSibling
import eu.darken.sdmse.automation.core.common.stepper.findNode
import eu.darken.sdmse.automation.core.common.stepper.findNodeByContentDesc
import eu.darken.sdmse.automation.core.common.stepper.findNodeByLabel
import eu.darken.sdmse.automation.core.input.InputInjector
import eu.darken.sdmse.automation.core.specs.AutomationExplorer
import eu.darken.sdmse.automation.core.specs.AutomationSpec
import eu.darken.sdmse.automation.core.specs.defaultFindAndClick
Expand All @@ -27,14 +34,14 @@ import eu.darken.sdmse.common.ca.toCaString
import eu.darken.sdmse.common.datastore.value
import eu.darken.sdmse.common.debug.Bugs
import eu.darken.sdmse.common.debug.logging.Logging.Priority.INFO
import eu.darken.sdmse.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.sdmse.common.debug.logging.Logging.Priority.WARN
import eu.darken.sdmse.common.debug.logging.log
import eu.darken.sdmse.common.debug.logging.logTag
import eu.darken.sdmse.common.debug.toVisualStrings
import eu.darken.sdmse.common.device.DeviceDetective
import eu.darken.sdmse.common.device.RomType
import eu.darken.sdmse.common.funnel.IPCFunnel
import eu.darken.sdmse.common.hasApiLevel
import eu.darken.sdmse.common.pkgs.features.Installed
import eu.darken.sdmse.common.pkgs.toPkgId
import eu.darken.sdmse.common.progress.withProgress
Expand All @@ -49,6 +56,7 @@ class AOSPSpecs @Inject constructor(
private val storageEntryFinder: StorageEntryFinder,
private val generalSettings: GeneralSettings,
private val stepper: Stepper,
private val inputInjector: InputInjector,
) : AppCleanerSpecGenerator {

override val tag: String = TAG
Expand All @@ -68,6 +76,95 @@ class AOSPSpecs @Inject constructor(
}
}

private suspend fun StepContext.findClearCacheCandidate(
labels: Collection<String>,
): ACSNodeInfo? {
findNodeByLabel(labels)?.let {
log(tag) { "Found candidate by text: $it" }
return it
}

if (hasApiLevel(36)) {
findNodeByContentDesc(labels) { it.isClickable }?.let {
log(tag, INFO) { "Found candidate by content-desc: $it" }
return it
}
}

return null
}

private suspend fun StepContext.resolveClickTarget(candidate: ACSNodeInfo): ACSNodeInfo? {
if (candidate.isClickyButton()) {
log(tag) { "Target is clicky button: $candidate" }
return candidate
}

// If candidate is clickable and not a TextView, use it directly
// This handles content-desc matches where the node IS the target (e.g., action2 LinearLayout)
if (candidate.isClickable && !candidate.isTextView()) {
log(tag) { "Target is clickable non-TextView: $candidate" }
return candidate
}

// For TextViews or non-clickable nodes, look for clickable parent/sibling
findClickableParent(node = candidate)?.let {
log(tag) { "Target is clickable parent: $it" }
return it
}

findClickableSibling(node = candidate)?.let {
log(tag) { "Target is clickable sibling: $it" }
return it
}

return null
}

private suspend fun StepContext.tryCoordinateBasedClick(): Boolean {
log(tag, INFO) { "Trying coordinate-based click (API 36+ fallback)" }

val headerNode = findNode {
it.viewIdResourceName?.contains("entity_header") == true
}

if (headerNode == null) {
log(tag, WARN) { "No entity_header anchor found" }
return false
}

val headerBounds = Rect().apply { headerNode.getBoundsInScreen(this) }
log(tag, INFO) { "Found entity_header at: $headerBounds" }

// Clear cache button: right side, below header
val buttonX = headerBounds.left + (headerBounds.width() * 0.74f)
val buttonY = headerBounds.bottom + 153f

log(tag, INFO) { "Coordinate-based click at ($buttonX, $buttonY) - targeting Clear cache" }
return clickGestureAtCoords(buttonX, buttonY, isDryRun = Bugs.isDryRun)
}

private suspend fun tryKeyboardNavigation(): Boolean {
log(tag, INFO) { "Trying keyboard navigation (API 36+ fallback)" }

if (!inputInjector.canInject()) {
log(tag, WARN) { "Cannot inject input events - no ADB or Root access" }
return false
}

// Navigate right 3 times to reach Clear cache button, then click
// Layout: [App icon] [Clear data] [Clear cache]
log(tag, INFO) { "Sending DPAD_RIGHT x3 + DPAD_CENTER" }
inputInjector.inject(
InputInjector.Event.DpadRight,
InputInjector.Event.DpadRight,
InputInjector.Event.DpadRight,
InputInjector.Event.DpadCenter,
)

return true
}

private val mainPlan: suspend AutomationExplorer.Context.(Installed) -> Unit = plan@{ pkg ->
log(TAG, INFO) { "Executing plan for ${pkg.installId} with context $this" }

Expand Down Expand Up @@ -96,42 +193,23 @@ class AOSPSpecs @Inject constructor(
log(TAG) { "clearCacheButtonLabels=${clearCacheButtonLabels.toVisualStrings()}" }

val nodeAction: suspend StepContext.() -> Boolean = action@{
var candidate = findNodeByLabel(clearCacheButtonLabels)
log(tag) { "Potential target is $candidate" }
if (candidate == null) return@action false

val clickableParent = findClickableParent(node = candidate)
log(tag, VERBOSE) { "Clickable parent is $clickableParent" }
val clickableSibling = findClickableSibling(node = candidate)
log(tag, VERBOSE) { "Clickable sibling is $clickableSibling" }

val target = when {
// ----------10: text='null', class=android.widget.LinearLayout, clickable=false, checkable=false enabled=true, id=null
// -----------11: text='Clear storage', class=android.widget.Button, clickable=true, checkable=false enabled=true, id=com.android.settings:id/button1
// -----------11: text='null', class=android.view.View, clickable=false, checkable=false enabled=true, id=com.android.settings:id/divider1
// -----------11: text='Clear cache', class=android.widget.Button, clickable=true, checkable=false enabled=true, id=com.android.settings:id/button2
candidate.isClickyButton() -> candidate.also { log(tag) { "Target is clicky button: $it" } }

// -----------11: text='null', class=android.widget.LinearLayout, clickable=true, checkable=false enabled=true, id=com.android.settings:id/action2
// ------------12: text='null', class=android.widget.Button, clickable=true, checkable=false enabled=true, id=com.android.settings:id/button2
// ------------12: text='Clear cache', class=android.widget.TextView, clickable=true, checkable=false enabled=true, id=com.android.settings:id/text2
clickableParent != null -> clickableParent.also { log(tag) { "Target is clickable parent: $it" } }

//-----------11: text='null', class=android.widget.LinearLayout, clickable=false, checkable=false enabled=true, id=com.android.settings:id/action2 pkg=com.android.settings, identity=bfaf7f6, bounds=Rect(540, 959 - 1020, 1239)
//------------12: text='null', class=android.widget.Button, clickable=true, checkable=false enabled=true, id=com.android.settings:id/button2 pkg=com.android.settings, identity=1eadc93, bounds=Rect(691, 959 - 869, 1098)
//------------12: text='Borrar caché', class=android.widget.TextView, clickable=false, checkable=false enabled=true, id=com.android.settings:id/text2 pkg=com.android.settings, identity=6d69d82, bounds=Rect(628, 1113 - 931, 1181)
clickableSibling != null -> clickableSibling.also { log(tag) { "Target is clickable sibling: $it" } }

else -> null
val candidate = findClearCacheCandidate(clearCacheButtonLabels)

if (candidate != null) {
val target = resolveClickTarget(candidate)
if (target != null) {
log(tag) { "Clicking Clear cache target: $target" }
return@action clickClearCache(isDryRun = Bugs.isDryRun, pkg = pkg, node = target)
}
log(tag, WARN) { "Could not resolve clickable target from: $candidate" }
}

if (target == null) {
log(tag, WARN) { "Mapped target for 'Clear cache' is null?" }
return@action false
if (hasApiLevel(36)) {
// Use keyboard navigation - uiautomator conflicts with accessibility service
return@action tryKeyboardNavigation()
}

log(tag) { "Clicking 'Clear cache' target $target for $pkg:" }
clickClearCache(isDryRun = Bugs.isDryRun, pkg = pkg, node = target)
false
}

val step = AutomationStep(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ACSNodeInfo {

// Basic properties
val text: CharSequence?
val contentDescription: CharSequence?
val className: CharSequence?
val packageName: CharSequence?
val viewIdResourceName: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ fun ACSNodeInfo.textContainsAny(candidates: Collection<String>): Boolean =
fun ACSNodeInfo.textEndsWithAny(candidates: Collection<String>): Boolean =
candidates.any { candidate -> textVariants.any { it.endsWith(candidate, ignoreCase = true) } }

val ACSNodeInfo.contentDescVariants: Set<String>
get() {
val target = contentDescription?.toString() ?: return emptySet()
return setOf(target, target.replace(' ', ' '))
}

fun ACSNodeInfo.contentDescMatches(candidate: String): Boolean {
return contentDescVariants.any { it.equals(candidate, ignoreCase = true) }
}

fun ACSNodeInfo.contentDescMatchesAny(candidates: Collection<String>): Boolean =
candidates.any { contentDescMatches(it) }

fun ACSNodeInfo.idMatches(id: String): Boolean {
return viewIdResourceName == id
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ private class AccessibilityNodeInfoWrapper(
) : ACSNodeInfo {

override val text: CharSequence? get() = node.text
override val contentDescription: CharSequence? get() = node.contentDescription
override val className: CharSequence? get() = node.className
override val packageName: CharSequence? get() = node.packageName
override val viewIdResourceName: String? get() = node.viewIdResourceName
Expand Down Expand Up @@ -39,7 +40,7 @@ private class AccessibilityNodeInfoWrapper(
override fun toString(): String {
val identity = Integer.toHexString(System.identityHashCode(this))
val bounds = Rect().apply { getBoundsInScreen(this) }
return "text='${this.text}', class=${this.className}, clickable=$isClickable, checkable=$isCheckable enabled=$isEnabled, id=$viewIdResourceName pkg=$packageName, identity=$identity, bounds=$bounds"
return "text='${this.text}', contentDesc='${this.contentDescription}', class=${this.className}, clickable=$isClickable, checkable=$isCheckable enabled=$isEnabled, id=$viewIdResourceName pkg=$packageName, identity=$identity, bounds=$bounds"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.graphics.Rect
import android.view.ViewConfiguration
import eu.darken.sdmse.automation.core.common.ACSNodeInfo
import eu.darken.sdmse.automation.core.common.children
import eu.darken.sdmse.automation.core.common.contentDescMatches
import eu.darken.sdmse.automation.core.common.crawl
import eu.darken.sdmse.automation.core.common.distanceTo
import eu.darken.sdmse.automation.core.common.findParentOrNull
Expand Down Expand Up @@ -42,6 +43,22 @@ suspend fun StepContext.findNodeByLabel(
}
}

/**
* Finds a node by content description, iterating through labels in priority order.
* Similar to [findNodeByLabel] but matches against contentDescription instead of text.
*
* This is useful on Android 16+ where button labels may be in content-desc rather than text.
*/
suspend fun StepContext.findNodeByContentDesc(
labels: Collection<String>,
predicate: (ACSNodeInfo) -> Boolean = { true },
): ACSNodeInfo? {
val tree = host.waitForWindowRoot().crawl().map { it.node }.toList()
return labels.firstNotNullOfOrNull { label ->
tree.find { it.contentDescMatches(label) && predicate(it) }
}
}

suspend fun StepContext.findClickableParent(
maxNesting: Int = 6,
includeSelf: Boolean = false,
Expand Down Expand Up @@ -142,6 +159,19 @@ suspend fun StepContext.clickGesture(
val rect = Rect().apply { node.getBoundsInScreen(this) }
val x = rect.centerX().toFloat()
val y = rect.centerY().toFloat()
log(tag, VERBOSE) { "clickGesture(): node=$node, bounds=$rect" }
return clickGestureAtCoords(x, y, isDryRun)
}

/**
* Performs a gesture click at specific screen coordinates.
* Used when target nodes are hidden from accessibility tree but position is known.
*/
suspend fun StepContext.clickGestureAtCoords(
x: Float,
y: Float,
isDryRun: Boolean = false,
): Boolean {
val path = Path().apply {
moveTo(x, y)
lineTo(x + 1f, y + 1f)
Expand All @@ -154,7 +184,7 @@ suspend fun StepContext.clickGesture(
host.changeOptions { it.copy(passthrough = true) }
host.state.filter { it.passthrough }.first()

log(tag) { "clickGesture(): Performing CLICK gesture at X=$x,Y=$y" }
log(tag) { "clickGestureAtCoords(): Performing CLICK gesture at X=$x, Y=$y" }
val success = if (isDryRun) true else host.dispatchGesture(gesture)

host.changeOptions { it.copy(passthrough = false) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package eu.darken.sdmse.automation.core.input

import eu.darken.sdmse.common.adb.AdbManager
import eu.darken.sdmse.common.adb.canUseAdbNow
import eu.darken.sdmse.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.sdmse.common.debug.logging.log
import eu.darken.sdmse.common.debug.logging.logTag
import eu.darken.sdmse.common.root.RootManager
import eu.darken.sdmse.common.root.canUseRootNow
import eu.darken.sdmse.common.shell.ShellOps
import eu.darken.sdmse.common.shell.ipc.ShellOpsCmd
import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class InputInjector @Inject constructor(
private val adbManager: AdbManager,
private val rootManager: RootManager,
private val shellOps: ShellOps,
) {

sealed class Event(
val command: String,
val delayAfter: Long = 100L,
) {
data object DpadUp : Event("input keyevent 19")
data object DpadDown : Event("input keyevent 20")
data object DpadLeft : Event("input keyevent 21")
data object DpadRight : Event("input keyevent 22")
data object DpadCenter : Event("input keyevent 23")
data object Enter : Event("input keyevent 66")
data object Tab : Event("input keyevent 61")
}

suspend fun canInject(): Boolean {
val adb = adbManager.canUseAdbNow()
val root = rootManager.canUseRootNow()
log(TAG, VERBOSE) { "canInject(): adb=$adb root=$root" }
return adb || root
}

private suspend fun getShellMode(): ShellOps.Mode = when {
adbManager.canUseAdbNow() -> ShellOps.Mode.ADB
rootManager.canUseRootNow() -> ShellOps.Mode.ROOT
else -> throw IllegalStateException("No ShellOps Mode available for input injection")
}

suspend fun inject(event: Event) {
log(TAG) { "inject($event): ${event.command}" }
val result = shellOps.execute(ShellOpsCmd(listOf(event.command)), getShellMode())
log(TAG) { "inject($event) result: $result" }
if (event.delayAfter > 0) delay(event.delayAfter)
}

suspend fun inject(vararg events: Event) {
log(TAG) { "inject(${events.size} events): ${events.map { it::class.simpleName }}" }
events.forEach { inject(it) }
}

suspend fun tap(x: Int, y: Int) {
log(TAG) { "tap($x, $y)" }
val result = shellOps.execute(ShellOpsCmd(listOf("input tap $x $y")), getShellMode())
log(TAG) { "tap($x, $y) result: $result" }
delay(100)
}

companion object {
val TAG: String = logTag("Automation", "InputInjector")
}
}
Loading