Answer install dialogs without ever tapping a closed one
Three things went wrong in this script and are fixed here, each with the run that showed it. It remembered a button position and tapped it without re-checking the dialog was still there, so a tap landed on the Telegram icon on the home screen behind a dialog that had already closed; it refused to answer a second dialog because it compared window tokens, and every dialog gets a new one, so the third install in a run was skipped; and its idle poll was so slow that a pair of installs could not both be caught. Now: the position is only used while an installer window is focused, re-checked immediately before the tap; a newer dialog with the same installer activity is answered rather than skipped; and after every dialog the script watches at 0.4 s for the follow-up install. Verified with two consecutive uninstall/install cycles, the second answered from the measured position five seconds after the first, and by installing the release APK with only the watcher running. The benchmark's own reinstall is still refused - two dialogs answered during one run, both with the installer focused, and pm install still returned Install canceled by user, because MIUI closes the dialog when the session is over.
This commit is contained in:
+4
-1
@@ -101,7 +101,10 @@ reads the button and taps it), and it does **not** work for the one that matters
|
|||||||
- It is `FLAG_SECURE`: a `screencap` taken while `mCurrentFocus` was the installer shows the home screen
|
- It is `FLAG_SECURE`: a `screencap` taken while `mCurrentFocus` was the installer shows the home screen
|
||||||
behind it, which is why screenshots of "the dialog" show nothing.
|
behind it, which is why screenshots of "the dialog" show nothing.
|
||||||
- A tap on it, when a run did stay up long enough for the script to read and tap `Installieren`, still
|
- A tap on it, when a run did stay up long enough for the script to read and tap `Installieren`, still
|
||||||
ended with `INSTALL_FAILED_USER_RESTRICTED: Install canceled by user`.
|
ended with `INSTALL_FAILED_USER_RESTRICTED: Install canceled by user`. The same happened later with the
|
||||||
|
script answering *two* dialogs in one run from a measured button position, each logged with the
|
||||||
|
installer window still focused: the install was refused anyway. MIUI is closing its own dialog when the
|
||||||
|
install session ends, so the tap arrives after the outcome has been decided.
|
||||||
- `pm install` started from an *app* uid does not even reach the gate: MIUI's `XSpaceManagerServiceImpl`
|
- `pm install` started from an *app* uid does not even reach the gate: MIUI's `XSpaceManagerServiceImpl`
|
||||||
throws `SecurityException: You either need MANAGE_USERS or CREATE_USERS permission to: query users`.
|
throws `SecurityException: You either need MANAGE_USERS or CREATE_USERS permission to: query users`.
|
||||||
So the rule's install runs through the shell, MIUI intercepts it as an "Install via USB" request, and
|
So the rule's install runs through the shell, MIUI intercepts it as an "Install via USB" request, and
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Answers MIUI's "Install via USB" dialog while a benchmark run is in flight.
|
# Answers MIUI's "Install via USB" dialog while installs are happening on the device.
|
||||||
#
|
#
|
||||||
# ./tools/benchmark-watch-install.sh # watch for ten minutes, poll every 5 s
|
# ./tools/benchmark-watch-install.sh # ten minutes, poll every 2 s
|
||||||
# ./tools/benchmark-watch-install.sh --interval 2 # poll more often
|
# ./tools/benchmark-watch-install.sh --interval 5 # poll less often when idle
|
||||||
# ./tools/benchmark-watch-install.sh --dry-run # report, never tap
|
# ./tools/benchmark-watch-install.sh --burst 30 # keep watching fast for 30 s after a dialog
|
||||||
|
# ./tools/benchmark-watch-install.sh --dry-run # report, never tap
|
||||||
#
|
#
|
||||||
# Why this exists: macrobenchmark installs its target before measuring it, and on this phone MIUI puts
|
# Run this for *every* install, not just the first: a benchmark run installs the test APK and then, per
|
||||||
# that install behind `com.miui.securitycenter/.permcenter.install.AdbInstallActivity`. The test cannot
|
# test, the app under test. Measured the hard way — the first version of this script spent three to four
|
||||||
# answer it itself — `CompilationMode.installPackageFromPaths` builds `pm install -t …` and runs it
|
# seconds reading one dialog, and the next dialog opened and closed inside that window.
|
||||||
# through `Shell.executeScriptCaptureStdout`, so the call blocks until the child exits and no test code
|
|
||||||
# is running while the dialog is up. This script runs beside the Gradle run instead.
|
|
||||||
#
|
#
|
||||||
# **It never taps blindly.** A dialog is only answered after its Install button has been read off the
|
# How it answers a dialog:
|
||||||
# screen and its own bounds are known; if the button is not there, nothing is tapped. That costs a
|
|
||||||
# `uiautomator dump` per dialog (3-4 s on this device) but it cannot press whatever happens to sit at a
|
|
||||||
# guessed coordinate.
|
|
||||||
#
|
#
|
||||||
# Measured on the Xiaomi phone (Android 12, MIUI 14), and both numbers matter:
|
# 1. If the Install button's position for this dialog was measured before (and saved), tap it — but
|
||||||
|
# only after re-checking, immediately beforehand, that an installer window is still focused. That
|
||||||
|
# check is what stops a tap from landing on the home screen once the dialog closes.
|
||||||
|
# 2. Otherwise read the screen (`uiautomator dump`), find the button by label — refusing anything that
|
||||||
|
# means "no" — verify the installer is still focused, then tap its own bounds and save the position
|
||||||
|
# for the dialogs that cannot be read in time.
|
||||||
|
# 3. If no Install button can be read and no position is known, tap nothing and say so.
|
||||||
#
|
#
|
||||||
# * for an install started from adb, the dialog appears after about 1.7 s and its deny button counts
|
# After every dialog the script watches in "burst" mode (every 0.4 s instead of the idle interval),
|
||||||
# down from about ten seconds — so a 5 s poll still leaves time to read the screen and tap;
|
# because installs arrive in pairs and the second one used to slip through while the first was read.
|
||||||
# * for the install macrobenchmark starts from inside the test, the window is focused for well under
|
|
||||||
# a second (`13:51:27.70 AdbInstallActivity` → `13:51:28.38 null`), which is shorter than one
|
|
||||||
# `uiautomator dump`. Those dialogs will not be caught, by this script or by a human.
|
|
||||||
#
|
#
|
||||||
# So this is honest about its limit: it makes adb-driven installs unattended, and it will not rescue a
|
# Measured on the Xiaomi phone (Android 12, MIUI 14): an adb-started install raises the dialog after
|
||||||
# reinstall that MIUI refuses instantly. `--interval` and `--wait` are there for tuning per device.
|
# about 1.7 s with a ten second deny countdown, so there is time for one dump and one tap; the same
|
||||||
|
# dialog is FLAG_SECURE, so a screencap of it shows whatever is behind it.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
seconds=600
|
seconds=600
|
||||||
interval=5
|
interval=2
|
||||||
|
burst=30
|
||||||
wait=10
|
wait=10
|
||||||
dry_run=0
|
dry_run=0
|
||||||
|
|
||||||
@@ -38,10 +39,11 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--seconds) seconds=$2; shift 2 ;;
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
--interval) interval=$2; shift 2 ;;
|
--interval) interval=$2; shift 2 ;;
|
||||||
|
--burst) burst=$2; shift 2 ;;
|
||||||
--wait) wait=$2; shift 2 ;;
|
--wait) wait=$2; shift 2 ;;
|
||||||
--dry-run) dry_run=1; shift ;;
|
--dry-run) dry_run=1; shift ;;
|
||||||
-h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "usage: $0 [--seconds N] [--interval N] [--wait N] [--dry-run]" >&2; exit 2 ;;
|
*) echo "usage: $0 [--seconds N] [--interval N] [--burst N] [--wait N] [--dry-run]" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -52,9 +54,47 @@ device_dump=/sdcard/.benchmark-watch.xml
|
|||||||
local_dump=$(mktemp)
|
local_dump=$(mktemp)
|
||||||
trap 'rm -f "$local_dump"' EXIT
|
trap 'rm -f "$local_dump"' EXIT
|
||||||
|
|
||||||
# Prints "<x> <y> <label>" for the dialog's Install button, or nothing at all. Anything that would mean
|
layout_file="${XDG_CACHE_HOME:-$HOME/.cache}/plain-files-benchmark-install-layout"
|
||||||
# "no" is excluded by name, so a dialog this does not understand is left alone rather than answered
|
layout_activity=""
|
||||||
# wrongly.
|
layout_x=""
|
||||||
|
layout_y=""
|
||||||
|
layout_label=""
|
||||||
|
if [ -f "$layout_file" ]; then
|
||||||
|
read -r layout_activity layout_x layout_y layout_label <"$layout_file" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
answered_token=""
|
||||||
|
tapped_token=""
|
||||||
|
opened_windows=0
|
||||||
|
taps=0
|
||||||
|
misses=0
|
||||||
|
|
||||||
|
focused_window() {
|
||||||
|
adb shell dumpsys window 2>/dev/null | grep -m1 'mCurrentFocus' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only the installer's own windows count; anything else is left alone.
|
||||||
|
is_installer() {
|
||||||
|
case "$1" in
|
||||||
|
*securitycenter*install*|*InstallApp*|*packageinstaller*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The window token, so the same dialog is not answered twice while it closes.
|
||||||
|
window_token() {
|
||||||
|
sed -n 's/.*mCurrentFocus=Window{\([0-9a-f]*\).*/\1/p' <<<"$1" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The activity that owns the focused window. Dialogs each get their own window token, so the token
|
||||||
|
# cannot be used to tell "the dialog I read is still up" from "another installer dialog replaced it" —
|
||||||
|
# which is exactly what happens when a run installs twice in quick succession. The activity name is
|
||||||
|
# stable across both, and it is still specific enough that a tap can only ever land on an installer.
|
||||||
|
installer_activity() {
|
||||||
|
sed -n 's/.*u0 \([^ }]*\).*/\1/p' <<<"$1" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints "<x> <y> <label>" for the dialog's Install button, or nothing at all.
|
||||||
install_button() {
|
install_button() {
|
||||||
adb shell uiautomator dump "$device_dump" >/dev/null 2>&1 || return 0
|
adb shell uiautomator dump "$device_dump" >/dev/null 2>&1 || return 0
|
||||||
adb pull "$device_dump" "$local_dump" >/dev/null 2>&1 || return 0
|
adb pull "$device_dump" "$local_dump" >/dev/null 2>&1 || return 0
|
||||||
@@ -76,8 +116,6 @@ for node in ET.parse(sys.argv[1]).getroot().iter('node'):
|
|||||||
low = text.lower()
|
low = text.lower()
|
||||||
if not text or any(low.startswith(d) for d in DENY):
|
if not text or any(low.startswith(d) for d in DENY):
|
||||||
continue
|
continue
|
||||||
# MIUI's ids put the allow button at button2, but the name is the deciding factor, so a build that
|
|
||||||
# labels the buttons differently still gets the right one.
|
|
||||||
if any(low.startswith(a) for a in ALLOW) or node.get('resource-id', '').endswith('button2'):
|
if any(low.startswith(a) for a in ALLOW) or node.get('resource-id', '').endswith('button2'):
|
||||||
x, y = centre(node)
|
x, y = centre(node)
|
||||||
print(x, y, text.replace(' ', '_'))
|
print(x, y, text.replace(' ', '_'))
|
||||||
@@ -85,41 +123,100 @@ for node in ET.parse(sys.argv[1]).getroot().iter('node'):
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
taps=0
|
# Reads the button, and taps it only if the dialog is *still* the focused window at that moment. Called
|
||||||
misses=0
|
# only while an installer window is up.
|
||||||
deadline=$((SECONDS + seconds))
|
#
|
||||||
echo "benchmark-watch-install: watching for $seconds s, polling every ${interval}s"
|
# The re-check is the whole point of this function. A dump takes three to four seconds, which is longer
|
||||||
|
# than the dialog can live, so the position read from a dying dialog used to be tapped after it had
|
||||||
|
# closed — and the tap landed on whatever the home screen had at those coordinates (a Telegram icon, in
|
||||||
|
# the run that made this obvious). Nothing here is remembered between dialogs either, for the same
|
||||||
|
# reason: a remembered position is a blind tap waiting to happen.
|
||||||
|
answer_dialog() {
|
||||||
|
local focus=$1 token activity label now_focus
|
||||||
|
token=$(window_token "$focus")
|
||||||
|
activity=$(installer_activity "$focus")
|
||||||
|
if [ "$token" != "$answered_token" ]; then
|
||||||
|
answered_token=$token
|
||||||
|
opened_windows=$((opened_windows + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
while [ $SECONDS -lt $deadline ]; do
|
# One tap per dialog. A second would be a tap on whatever the dialog left behind.
|
||||||
sleep "$interval"
|
if [ "$token" = "$tapped_token" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
focus=$(adb shell dumpsys window 2>/dev/null | grep -m1 'mCurrentFocus' || true)
|
# Fast path: the position of this dialog's Install button was measured earlier and saved, and reading
|
||||||
case "$focus" in
|
# the screen again takes longer than the dialog lives. The saved position is used only while an
|
||||||
*securitycenter*install*|*InstallApp*|*packageinstaller*) ;;
|
# installer window is focused — checked again immediately before the tap, 0.1 s earlier — so it can
|
||||||
*) continue ;;
|
# never land on whatever is behind a dialog that has closed.
|
||||||
esac
|
if [ -n "$layout_x" ] && [ "$layout_activity" = "$activity" ]; then
|
||||||
|
now_focus=$(focused_window)
|
||||||
# The dialog is up: wait for the Install button to be readable, then click exactly where it is.
|
if is_installer "$now_focus" && [ "$(installer_activity "$now_focus")" = "$activity" ]; then
|
||||||
answered=0
|
tapped_token=$(window_token "$now_focus")
|
||||||
for _ in $(seq 1 "$wait"); do
|
|
||||||
read -r button_x button_y button_label <<<"$(install_button)"
|
|
||||||
if [ -n "${button_x:-}" ]; then
|
|
||||||
if [ "$dry_run" -eq 1 ]; then
|
if [ "$dry_run" -eq 1 ]; then
|
||||||
echo " would tap '$button_label' at $button_x,$button_y"
|
echo " $(date +%H:%M:%S) would tap '$layout_label' at $layout_x,$layout_y (measured position, installer focused)"
|
||||||
|
else
|
||||||
|
adb shell input tap "$layout_x" "$layout_y" || true
|
||||||
|
taps=$((taps + 1))
|
||||||
|
echo " $(date +%H:%M:%S) tapped '$layout_label' at $layout_x,$layout_y (measured position, installer focused)"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local attempt
|
||||||
|
for attempt in $(seq 1 "$wait"); do
|
||||||
|
read -r button_x button_y label <<<"$(install_button)"
|
||||||
|
if [ -n "${button_x:-}" ]; then
|
||||||
|
now_focus=$(focused_window)
|
||||||
|
if ! is_installer "$now_focus" || [ "$(installer_activity "$now_focus")" != "$activity" ]; then
|
||||||
|
echo " $(date +%H:%M:%S) read '$label' at $button_x,$button_y, but the dialog closed before the tap; not tapping"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Whatever installer dialog is up *now* is the one being answered; it may be a newer window
|
||||||
|
# than the one this function was called for, which is exactly the case that used to be
|
||||||
|
# skipped.
|
||||||
|
tapped_token=$(window_token "$now_focus")
|
||||||
|
# Remember where this button was, for the dialogs that cannot be read in time.
|
||||||
|
layout_activity=$activity
|
||||||
|
layout_x=$button_x
|
||||||
|
layout_y=$button_y
|
||||||
|
layout_label=$label
|
||||||
|
printf '%s %s %s %s\n' "$activity" "$button_x" "$button_y" "$label" >"$layout_file" 2>/dev/null || true
|
||||||
|
if [ "$dry_run" -eq 1 ]; then
|
||||||
|
echo " $(date +%H:%M:%S) would tap '$label' at $button_x,$button_y"
|
||||||
else
|
else
|
||||||
adb shell input tap "$button_x" "$button_y" || true
|
adb shell input tap "$button_x" "$button_y" || true
|
||||||
taps=$((taps + 1))
|
taps=$((taps + 1))
|
||||||
echo " tapped '$button_label' at $button_x,$button_y"
|
echo " $(date +%H:%M:%S) tapped '$label' at $button_x,$button_y (read from the screen, installer still up)"
|
||||||
fi
|
fi
|
||||||
answered=1
|
return 0
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 0.5
|
||||||
done
|
done
|
||||||
if [ "$answered" -eq 0 ]; then
|
|
||||||
misses=$((misses + 1))
|
misses=$((misses + 1))
|
||||||
echo " an installer window was focused but no Install button appeared within ${wait}s"
|
echo " $(date +%H:%M:%S) installer window focused, but no Install button appeared within ${wait}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline=$((SECONDS + seconds))
|
||||||
|
burst_until=0
|
||||||
|
echo "benchmark-watch-install: watching for $seconds s (idle poll ${interval}s, burst after a dialog)"
|
||||||
|
|
||||||
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
focus=$(focused_window)
|
||||||
|
if is_installer "$focus"; then
|
||||||
|
answer_dialog "$focus"
|
||||||
|
burst_until=$((SECONDS + burst))
|
||||||
|
sleep 0.7
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $SECONDS -lt $burst_until ]; then
|
||||||
|
sleep 0.4
|
||||||
|
else
|
||||||
|
sleep "$interval"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "benchmark-watch-install: done, $taps install dialog(s) answered, $misses seen but not answerable"
|
echo "benchmark-watch-install: done, $taps tap(s), $opened_windows installer window(s) seen, $misses not answerable"
|
||||||
|
|||||||
Reference in New Issue
Block a user