Skip to content

Commit 4a58942

Browse files
committed
test(android): add accessibility service probe for the Flutter host
This commit is optional. I like it. You may not and in that case may drop it at will. run.py builds a plugin-free Flutter app with the production MainActivity template and runs a tool and a non-tool accessibility service against fake secrets, before and after activity recreation. --scope none drops the root layout override as a positive control.
1 parent dc43cb2 commit 4a58942

3 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.cypherstack.accessibility_probe
2+
3+
import android.accessibilityservice.AccessibilityService
4+
import android.app.Activity
5+
import android.app.Application
6+
import android.content.BroadcastReceiver
7+
import android.content.Context
8+
import android.os.Handler
9+
import android.os.Looper
10+
import java.io.File
11+
import android.content.Intent
12+
import android.os.Build
13+
import android.os.Bundle
14+
import android.view.accessibility.AccessibilityEvent
15+
import android.view.accessibility.AccessibilityNodeInfo
16+
import java.util.concurrent.CopyOnWriteArrayList
17+
18+
abstract class ProbeService : AccessibilityService() {
19+
val events = CopyOnWriteArrayList<String>()
20+
override fun onAccessibilityEvent(event: AccessibilityEvent) {
21+
events.add((event.text + listOf(event.beforeText, event.contentDescription)).joinToString(" "))
22+
}
23+
override fun onInterrupt() {}
24+
fun tree(): String {
25+
val result = StringBuilder()
26+
fun visit(node: AccessibilityNodeInfo?, depth: Int) {
27+
if (node == null || depth > 50) return
28+
result.append(node.text).append(' ').append(node.contentDescription).append('\n')
29+
for (i in 0 until node.childCount) visit(node.getChild(i), depth + 1)
30+
node.recycle()
31+
}
32+
visit(rootInActiveWindow, 0)
33+
return result.toString()
34+
}
35+
}
36+
class ToolProbeService : ProbeService() {
37+
companion object { @Volatile var instance: ToolProbeService? = null }
38+
override fun onServiceConnected() { instance = this }
39+
override fun onDestroy() { instance = null; super.onDestroy() }
40+
}
41+
class NonToolProbeService : ProbeService() {
42+
companion object { @Volatile var instance: NonToolProbeService? = null }
43+
override fun onServiceConnected() { instance = this }
44+
override fun onDestroy() { instance = null; super.onDestroy() }
45+
}
46+
class ProbeApplication : Application(), Application.ActivityLifecycleCallbacks {
47+
companion object { @Volatile var activity: Activity? = null }
48+
override fun onCreate() { super.onCreate(); registerActivityLifecycleCallbacks(this) }
49+
override fun onActivityCreated(value: Activity, state: Bundle?) { activity = value }
50+
override fun onActivityResumed(value: Activity) { activity = value }
51+
override fun onActivityStarted(value: Activity) {}
52+
override fun onActivityPaused(value: Activity) {}
53+
override fun onActivityStopped(value: Activity) {}
54+
override fun onActivitySaveInstanceState(value: Activity, state: Bundle) {}
55+
override fun onActivityDestroyed(value: Activity) { if (activity === value) activity = null }
56+
}
57+
class ProbeReceiver : BroadcastReceiver() {
58+
override fun onReceive(context: Context, intent: Intent) {
59+
val pending = goAsync()
60+
Thread {
61+
val result = File(context.filesDir, "accessibility-result.txt")
62+
try {
63+
ProbeChecks(intent.getStringExtra("scope") ?: "host").run()
64+
result.writeText("PASS: node queries and text events, before/after activity recreation")
65+
} catch (error: Throwable) {
66+
result.writeText("FAIL: ${error.stackTraceToString()}")
67+
} finally { pending.finish() }
68+
}.start()
69+
}
70+
}
71+
class ProbeChecks(private val scope: String) {
72+
private fun await(message: String, predicate: () -> Boolean) {
73+
val deadline = System.currentTimeMillis() + 20000
74+
while (System.currentTimeMillis() < deadline) {
75+
if (predicate()) return
76+
Thread.sleep(100)
77+
}
78+
error(message)
79+
}
80+
fun run() {
81+
await("Both test accessibility services must be enabled") {
82+
ToolProbeService.instance != null && NonToolProbeService.instance != null
83+
}
84+
checkPhase("initial")
85+
val activity = ProbeApplication.activity ?: error("No probe activity")
86+
Handler(Looper.getMainLooper()).post { activity.recreate() }
87+
await("Activity did not recreate") {
88+
ProbeApplication.activity != null && ProbeApplication.activity !== activity
89+
}
90+
checkPhase("recreated")
91+
}
92+
private fun checkPhase(phase: String) {
93+
val tool = ToolProbeService.instance!!
94+
val nonTool = NonToolProbeService.instance!!
95+
val protected = Build.VERSION.SDK_INT >= 34 && scope != "none"
96+
await("$phase: tool cannot read the seed/input") {
97+
val tree = tool.tree()
98+
tree.contains("seed-probe") && tree.contains("private-probe") && tree.contains("public-probe")
99+
}
100+
if (protected) check(nonTool.events.none { it.contains("private-probe") || it.contains("seed-probe") }) {
101+
"$phase: non-tool received a secret during startup or recreation"
102+
}
103+
tool.events.clear()
104+
nonTool.events.clear()
105+
await("$phase: no positive-control input events for tool") {
106+
tool.events.any { it.contains("private-probe") }
107+
}
108+
repeat(20) {
109+
val tree = nonTool.tree()
110+
if (protected) {
111+
check(!tree.contains("seed-probe") && !tree.contains("private-probe")) {
112+
"$phase: non-tool can query secrets"
113+
}
114+
check(nonTool.events.none { it.contains("private-probe") || it.contains("seed-probe") }) {
115+
"$phase: non-tool received secret events"
116+
}
117+
} else {
118+
check(tree.contains("seed-probe") && tree.contains("private-probe")) {
119+
"$phase: baseline/older-API positive control failed"
120+
}
121+
}
122+
Thread.sleep(100)
123+
}
124+
if (!protected) check(nonTool.events.any { it.contains("private-probe") }) {
125+
"$phase: baseline/older-API event positive control failed"
126+
}
127+
}
128+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/material.dart';
4+
5+
void main() => runApp(const MaterialApp(home: Probe()));
6+
7+
class Probe extends StatefulWidget {
8+
const Probe({super.key});
9+
@override
10+
State<Probe> createState() => _ProbeState();
11+
}
12+
13+
class _ProbeState extends State<Probe> {
14+
final controller = TextEditingController(text: 'private-probe-0');
15+
Timer? timer;
16+
int count = 0;
17+
18+
@override
19+
void initState() {
20+
super.initState();
21+
timer = Timer.periodic(const Duration(seconds: 1), (_) {
22+
controller.text = 'private-probe-${++count}';
23+
});
24+
}
25+
26+
@override
27+
void dispose() {
28+
timer?.cancel();
29+
controller.dispose();
30+
super.dispose();
31+
}
32+
33+
@override
34+
Widget build(BuildContext context) => Scaffold(
35+
body: Column(
36+
children: [
37+
const Text('public-probe'),
38+
const Text('seed-probe'),
39+
TextField(controller: controller, autofocus: true),
40+
],
41+
),
42+
);
43+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import pathlib
4+
import shutil
5+
import subprocess
6+
import tempfile
7+
import time
8+
9+
parser = argparse.ArgumentParser()
10+
parser.add_argument('--flutter', default='flutter')
11+
parser.add_argument('--adb', default='adb')
12+
parser.add_argument('--device', required=True)
13+
parser.add_argument('--scope', choices=['host', 'none'], default='host')
14+
parser.add_argument('--work-dir')
15+
args = parser.parse_args()
16+
if not args.device.startswith('emulator-'):
17+
parser.error('Use a disposable emulator; the probe enables test accessibility services.')
18+
19+
here = pathlib.Path(__file__).resolve().parent
20+
repo = here.parents[1]
21+
work = pathlib.Path(args.work_dir or tempfile.mkdtemp(prefix='stack-a11y-')).resolve()
22+
app = work / 'app'
23+
package = 'com.cypherstack.accessibility_probe'
24+
25+
def run(command, **kwargs):
26+
return subprocess.run(command, check=True, text=True, **kwargs)
27+
28+
def adb(*command, capture=False):
29+
return run([args.adb, '-s', args.device, *command], capture_output=capture)
30+
31+
if not app.exists():
32+
run([args.flutter, 'create', '--empty', '--platforms=android', '--org',
33+
'com.cypherstack', '--project-name', 'accessibility_probe', str(app)])
34+
shutil.copyfile(here / 'main.dart', app / 'lib/main.dart')
35+
activity = (repo / 'scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt').read_text()
36+
activity = activity.replace('package com.place.holder', f'package {package}')
37+
if args.scope == 'none':
38+
start = activity.index(' override fun provideRootLayout')
39+
end = activity.index(' var openPath:', start)
40+
activity = activity[:start] + activity[end:]
41+
kotlin = app / 'android/app/src/main/kotlin/com/cypherstack/accessibility_probe'
42+
kotlin.mkdir(parents=True, exist_ok=True)
43+
(kotlin / 'MainActivity.kt').write_text(activity)
44+
shutil.copyfile(here / 'Probe.kt', kotlin / 'Probe.kt')
45+
xml = app / 'android/app/src/main/res/xml'
46+
xml.mkdir(exist_ok=True)
47+
for name, tool in [('tool', 'true'), ('non_tool', 'false')]:
48+
(xml / f'{name}.xml').write_text(f'''<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
49+
android:accessibilityEventTypes="typeAllMask"
50+
android:accessibilityFeedbackType="feedbackGeneric"
51+
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
52+
android:canRetrieveWindowContent="true"
53+
android:isAccessibilityTool="{tool}"
54+
android:packageNames="{package}" />''')
55+
manifest_path = app / 'android/app/src/main/AndroidManifest.xml'
56+
manifest = manifest_path.read_text().replace('${applicationName}', '.ProbeApplication')
57+
start_marker, end_marker = '<!-- PROBE START -->', '<!-- PROBE END -->'
58+
while start_marker in manifest:
59+
start = manifest.index(start_marker)
60+
end = manifest.index(end_marker, start) + len(end_marker)
61+
manifest = manifest[:start] + manifest[end:]
62+
services = ''
63+
for cls, xml_name in [('ToolProbeService', 'tool'), ('NonToolProbeService', 'non_tool')]:
64+
services += f'''<service android:name=".{cls}" android:exported="true"
65+
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
66+
<intent-filter><action android:name="android.accessibilityservice.AccessibilityService" /></intent-filter>
67+
<meta-data android:name="android.accessibilityservice" android:resource="@xml/{xml_name}" />
68+
</service>'''
69+
services += '<receiver android:name=".ProbeReceiver" android:exported="true" />'
70+
manifest = manifest.replace('</application>', start_marker + services + end_marker + '</application>')
71+
manifest_path.write_text(manifest)
72+
run([args.flutter, 'build', 'apk', '--debug', '--target-platform', 'android-x64'], cwd=app)
73+
adb('install', '-r', str(app / 'build/app/outputs/flutter-apk/app-debug.apk'))
74+
old_services = adb('shell', 'settings', 'get', 'secure', 'enabled_accessibility_services', capture=True).stdout.strip()
75+
old_enabled = adb('shell', 'settings', 'get', 'secure', 'accessibility_enabled', capture=True).stdout.strip()
76+
try:
77+
services = f'{package}/.ToolProbeService:{package}/.NonToolProbeService'
78+
if old_services and old_services != 'null':
79+
services = old_services + ':' + services
80+
adb('shell', 'settings', 'put', 'secure', 'enabled_accessibility_services', services)
81+
adb('shell', 'settings', 'put', 'secure', 'accessibility_enabled', '1')
82+
adb('shell', 'run-as', package, 'rm', '-f', 'files/accessibility-result.txt')
83+
adb('shell', 'am', 'start', '-n', f'{package}/.MainActivity')
84+
adb('shell', 'am', 'broadcast', '-n', f'{package}/.ProbeReceiver', '--es', 'scope', args.scope)
85+
deadline = time.monotonic() + 90
86+
output = ''
87+
while time.monotonic() < deadline:
88+
result = subprocess.run([args.adb, '-s', args.device, 'shell', 'run-as', package,
89+
'cat', 'files/accessibility-result.txt'], text=True, capture_output=True)
90+
if result.returncode == 0 and result.stdout:
91+
output = result.stdout
92+
break
93+
time.sleep(1)
94+
print(output)
95+
if not output.startswith('PASS:'):
96+
raise SystemExit('Accessibility integration check failed or timed out')
97+
98+
finally:
99+
for key, value in [('enabled_accessibility_services', old_services), ('accessibility_enabled', old_enabled)]:
100+
if value == 'null':
101+
adb('shell', 'settings', 'delete', 'secure', key)
102+
else:
103+
adb('shell', 'settings', 'put', 'secure', key, value)

0 commit comments

Comments
 (0)