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
128 changes: 128 additions & 0 deletions integration_test/android_accessibility/Probe.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.cypherstack.accessibility_probe

import android.accessibilityservice.AccessibilityService
import android.app.Activity
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.os.Handler
import android.os.Looper
import java.io.File
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import java.util.concurrent.CopyOnWriteArrayList

abstract class ProbeService : AccessibilityService() {
val events = CopyOnWriteArrayList<String>()
override fun onAccessibilityEvent(event: AccessibilityEvent) {
events.add((event.text + listOf(event.beforeText, event.contentDescription)).joinToString(" "))
}
override fun onInterrupt() {}
fun tree(): String {
val result = StringBuilder()
fun visit(node: AccessibilityNodeInfo?, depth: Int) {
if (node == null || depth > 50) return
result.append(node.text).append(' ').append(node.contentDescription).append('\n')
for (i in 0 until node.childCount) visit(node.getChild(i), depth + 1)
node.recycle()
}
visit(rootInActiveWindow, 0)
return result.toString()
}
}
class ToolProbeService : ProbeService() {
companion object { @Volatile var instance: ToolProbeService? = null }
override fun onServiceConnected() { instance = this }
override fun onDestroy() { instance = null; super.onDestroy() }
}
class NonToolProbeService : ProbeService() {
companion object { @Volatile var instance: NonToolProbeService? = null }
override fun onServiceConnected() { instance = this }
override fun onDestroy() { instance = null; super.onDestroy() }
}
class ProbeApplication : Application(), Application.ActivityLifecycleCallbacks {
companion object { @Volatile var activity: Activity? = null }
override fun onCreate() { super.onCreate(); registerActivityLifecycleCallbacks(this) }
override fun onActivityCreated(value: Activity, state: Bundle?) { activity = value }
override fun onActivityResumed(value: Activity) { activity = value }
override fun onActivityStarted(value: Activity) {}
override fun onActivityPaused(value: Activity) {}
override fun onActivityStopped(value: Activity) {}
override fun onActivitySaveInstanceState(value: Activity, state: Bundle) {}
override fun onActivityDestroyed(value: Activity) { if (activity === value) activity = null }
}
class ProbeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
Thread {
val result = File(context.filesDir, "accessibility-result.txt")
try {
ProbeChecks(intent.getStringExtra("scope") ?: "host").run()
result.writeText("PASS: node queries and text events, before/after activity recreation")
} catch (error: Throwable) {
result.writeText("FAIL: ${error.stackTraceToString()}")
} finally { pending.finish() }
}.start()
}
}
class ProbeChecks(private val scope: String) {
private fun await(message: String, predicate: () -> Boolean) {
val deadline = System.currentTimeMillis() + 20000
while (System.currentTimeMillis() < deadline) {
if (predicate()) return
Thread.sleep(100)
}
error(message)
}
fun run() {
await("Both test accessibility services must be enabled") {
ToolProbeService.instance != null && NonToolProbeService.instance != null
}
checkPhase("initial")
val activity = ProbeApplication.activity ?: error("No probe activity")
Handler(Looper.getMainLooper()).post { activity.recreate() }
await("Activity did not recreate") {
ProbeApplication.activity != null && ProbeApplication.activity !== activity
}
checkPhase("recreated")
}
private fun checkPhase(phase: String) {
val tool = ToolProbeService.instance!!
val nonTool = NonToolProbeService.instance!!
val protected = Build.VERSION.SDK_INT >= 34 && scope != "none"
await("$phase: tool cannot read the seed/input") {
val tree = tool.tree()
tree.contains("seed-probe") && tree.contains("private-probe") && tree.contains("public-probe")
}
if (protected) check(nonTool.events.none { it.contains("private-probe") || it.contains("seed-probe") }) {
"$phase: non-tool received a secret during startup or recreation"
}
tool.events.clear()
nonTool.events.clear()
await("$phase: no positive-control input events for tool") {
tool.events.any { it.contains("private-probe") }
}
repeat(20) {
val tree = nonTool.tree()
if (protected) {
check(!tree.contains("seed-probe") && !tree.contains("private-probe")) {
"$phase: non-tool can query secrets"
}
check(nonTool.events.none { it.contains("private-probe") || it.contains("seed-probe") }) {
"$phase: non-tool received secret events"
}
} else {
check(tree.contains("seed-probe") && tree.contains("private-probe")) {
"$phase: baseline/older-API positive control failed"
}
}
Thread.sleep(100)
}
if (!protected) check(nonTool.events.any { it.contains("private-probe") }) {
"$phase: baseline/older-API event positive control failed"
}
}
}
43 changes: 43 additions & 0 deletions integration_test/android_accessibility/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'dart:async';

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: Probe()));

class Probe extends StatefulWidget {
const Probe({super.key});
@override
State<Probe> createState() => _ProbeState();
}

class _ProbeState extends State<Probe> {
final controller = TextEditingController(text: 'private-probe-0');
Timer? timer;
int count = 0;

@override
void initState() {
super.initState();
timer = Timer.periodic(const Duration(seconds: 1), (_) {
controller.text = 'private-probe-${++count}';
});
}

@override
void dispose() {
timer?.cancel();
controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) => Scaffold(
body: Column(
children: [
const Text('public-probe'),
const Text('seed-probe'),
TextField(controller: controller, autofocus: true),
],
),
);
}
103 changes: 103 additions & 0 deletions integration_test/android_accessibility/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import argparse
import pathlib
import shutil
import subprocess
import tempfile
import time

parser = argparse.ArgumentParser()
parser.add_argument('--flutter', default='flutter')
parser.add_argument('--adb', default='adb')
parser.add_argument('--device', required=True)
parser.add_argument('--scope', choices=['host', 'none'], default='host')
parser.add_argument('--work-dir')
args = parser.parse_args()
if not args.device.startswith('emulator-'):
parser.error('Use a disposable emulator; the probe enables test accessibility services.')

here = pathlib.Path(__file__).resolve().parent
repo = here.parents[1]
work = pathlib.Path(args.work_dir or tempfile.mkdtemp(prefix='stack-a11y-')).resolve()
app = work / 'app'
package = 'com.cypherstack.accessibility_probe'

def run(command, **kwargs):
return subprocess.run(command, check=True, text=True, **kwargs)

def adb(*command, capture=False):
return run([args.adb, '-s', args.device, *command], capture_output=capture)

if not app.exists():
run([args.flutter, 'create', '--empty', '--platforms=android', '--org',
'com.cypherstack', '--project-name', 'accessibility_probe', str(app)])
shutil.copyfile(here / 'main.dart', app / 'lib/main.dart')
activity = (repo / 'scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt').read_text()
activity = activity.replace('package com.place.holder', f'package {package}')
if args.scope == 'none':
start = activity.index(' override fun provideRootLayout')
end = activity.index(' var openPath:', start)
activity = activity[:start] + activity[end:]
kotlin = app / 'android/app/src/main/kotlin/com/cypherstack/accessibility_probe'
kotlin.mkdir(parents=True, exist_ok=True)
(kotlin / 'MainActivity.kt').write_text(activity)
shutil.copyfile(here / 'Probe.kt', kotlin / 'Probe.kt')
xml = app / 'android/app/src/main/res/xml'
xml.mkdir(exist_ok=True)
for name, tool in [('tool', 'true'), ('non_tool', 'false')]:
(xml / f'{name}.xml').write_text(f'''<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:isAccessibilityTool="{tool}"
android:packageNames="{package}" />''')
manifest_path = app / 'android/app/src/main/AndroidManifest.xml'
manifest = manifest_path.read_text().replace('${applicationName}', '.ProbeApplication')
start_marker, end_marker = '<!-- PROBE START -->', '<!-- PROBE END -->'
while start_marker in manifest:
start = manifest.index(start_marker)
end = manifest.index(end_marker, start) + len(end_marker)
manifest = manifest[:start] + manifest[end:]
services = ''
for cls, xml_name in [('ToolProbeService', 'tool'), ('NonToolProbeService', 'non_tool')]:
services += f'''<service android:name=".{cls}" android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter><action android:name="android.accessibilityservice.AccessibilityService" /></intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/{xml_name}" />
</service>'''
services += '<receiver android:name=".ProbeReceiver" android:exported="true" />'
manifest = manifest.replace('</application>', start_marker + services + end_marker + '</application>')
manifest_path.write_text(manifest)
run([args.flutter, 'build', 'apk', '--debug', '--target-platform', 'android-x64'], cwd=app)
adb('install', '-r', str(app / 'build/app/outputs/flutter-apk/app-debug.apk'))
old_services = adb('shell', 'settings', 'get', 'secure', 'enabled_accessibility_services', capture=True).stdout.strip()
old_enabled = adb('shell', 'settings', 'get', 'secure', 'accessibility_enabled', capture=True).stdout.strip()
try:
services = f'{package}/.ToolProbeService:{package}/.NonToolProbeService'
if old_services and old_services != 'null':
services = old_services + ':' + services
adb('shell', 'settings', 'put', 'secure', 'enabled_accessibility_services', services)
adb('shell', 'settings', 'put', 'secure', 'accessibility_enabled', '1')
adb('shell', 'run-as', package, 'rm', '-f', 'files/accessibility-result.txt')
adb('shell', 'am', 'start', '-n', f'{package}/.MainActivity')
adb('shell', 'am', 'broadcast', '-n', f'{package}/.ProbeReceiver', '--es', 'scope', args.scope)
deadline = time.monotonic() + 90
output = ''
while time.monotonic() < deadline:
result = subprocess.run([args.adb, '-s', args.device, 'shell', 'run-as', package,
'cat', 'files/accessibility-result.txt'], text=True, capture_output=True)
if result.returncode == 0 and result.stdout:
output = result.stdout
break
time.sleep(1)
print(output)
if not output.startswith('PASS:'):
raise SystemExit('Accessibility integration check failed or timed out')

finally:
for key, value in [('enabled_accessibility_services', old_services), ('accessibility_enabled', old_enabled)]:
if value == 'null':
adb('shell', 'settings', 'delete', 'secure', key)
else:
adb('shell', 'settings', 'put', 'secure', key, value)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.place.holder
import androidx.annotation.NonNull;
import io.flutter.embedding.android.FlutterFragmentActivity
import android.content.Intent
import android.content.Context
import android.os.Bundle
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
Expand All @@ -13,9 +14,20 @@ import java.nio.charset.Charset
import android.os.Build
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.view.View
import android.widget.FrameLayout
class MainActivity: FlutterFragmentActivity() {
private val CHANNEL = "STACK_WALLET_RESTORE"

override fun provideRootLayout(context: Context): FrameLayout {
return super.provideRootLayout(context).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// FlutterView inherits this, so only isAccessibilityTool services can read it.
setAccessibilityDataSensitive(View.ACCESSIBILITY_DATA_SENSITIVE_YES)
}
}
}

var openPath: String? = null
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine)
Expand Down
Loading