commit 2cf3cbad567fe19b7f29074434b75f2cabaa0e3c Author: kirl <18827922006@163.com> Date: Tue Sep 1 16:19:16 2026 +0800 feat: 4ma 用车计时助手 v1.0 - 无障碍服务监听7MA出行小程序,匹配关键词自动开始/结束计时 - 事件驱动截图+ML Kit离线中文OCR兜底,解决WebView无障碍树不稳定 - 常驻计时通知(15/18/30分钟提醒)与结束用车按钮 - 浅色主题主界面:权限状态、具体实现、安全/免责/法律声明 - 电池白名单+自启动引导+onUnbind自动重绑守护 - 无网络权限,完全离线运行 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c27b900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Gradle +.gradle/ +build/ + +# Android +app/build/ +*.apk +local.properties + +# 自包含的工具链(体积大,若用 git 建议忽略;重装见 SETUP.md) +android-sdk/ +tools/* +!tools/make_icons.py + +# Python +.venv/ + +# 调试产物(日志 / 截图 / dump,位于项目根目录) +/lc*.txt +/logcat.txt +/a11y_dump.txt +/notif*.txt +/live_*.log +/*.png +/ui*.xml + +# IDE / OS +.idea/ +*.iml +.DS_Store diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..828ad43 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,160 @@ +# 无障碍助手(Accessibility Helper)最小开发环境 + +> 日期:2026-09-01 +> 目标:用**不依赖 Android Studio** 的最简命令行环境开发一个申请无障碍权限的辅助小工具。 +> 全部工具链均位于本工作区(`c:\Users\Tan\Desktop\7maHelper`),自包含、可复现。 + +--- + +## 一、环境总览 + +| 组件 | 版本 | 是否必需 | 说明 | +|------|------|---------|------| +| JDK | 17.0.20.1 LTS | ✅ | 编译 Java/Kotlin;AGP 8.x 要求 JDK 17+ | +| Android SDK cmdline-tools | latest (11076708) | ✅ | 提供 `sdkmanager` / `avdmanager` | +| platform-tools (adb) | 37.0.1 | ✅ | 设备连接、APK 安装 | +| Android 平台 | android-35 | ✅ | `compileSdk 35` 需要 | +| build-tools | 35.0.0 / 34.0.0 | ✅ | 34 为 AGP 8.7 默认,自动补装 | +| Gradle | 8.10.2 | ✅ | 构建工具(含 Wrapper) | +| Android Gradle Plugin | 8.7.2 | ✅ | 自动下载 | +| Android Studio | — | ❌ | **本方案不使用** | + +--- + +## 二、已安装组件及路径 + +### 1. JDK 17 (Microsoft OpenJDK) +- **安装方式**:`winget install Microsoft.OpenJDK.17` +- **路径**:`C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot` +- **验证**:`java -version` → `openjdk version "17.0.20.1" 2026-08-18 LTS` + +### 2. Android SDK(位于工作区) +- **根目录**:`c:\Users\Tan\Desktop\7maHelper\android-sdk` +- **cmdline-tools**:`android-sdk\cmdline-tools\latest`(含 `bin\sdkmanager.bat`) +- **platform-tools**:`android-sdk\platform-tools\adb.exe` +- **platforms**:`android-sdk\platforms\android-35` +- **build-tools**:`android-sdk\build-tools\35.0.0`、`android-sdk\build-tools\34.0.0` +- **licenses**:`android-sdk\licenses`(已接受全部许可) +- **下载来源**:`https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip` + +### 3. Gradle 8.10.2(位于工作区) +- **发行版**:`c:\Users\Tan\Desktop\7maHelper\tools\gradle-8.10.2` +- **可执行**:`tools\gradle-8.10.2\bin\gradle.bat` +- **下载来源**:`https://services.gradle.org/distributions/gradle-8.10.2-bin.zip` +- **Wrapper**:项目内有 `gradlew.bat`(首次使用会从其下载源拉取发行版到 `%USERPROFILE%\.gradle\wrapper\dists`) + +### 4. 已配置的持久化环境变量(`setx`) +``` +JAVA_HOME = C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot +ANDROID_HOME = C:\Users\Tan\Desktop\7maHelper\android-sdk +ANDROID_SDK_ROOT = C:\Users\Tan\Desktop\7maHelper\android-sdk +``` +- `setx` 只对**新打开的终端**生效;当前会话需手动加载。 +- 免重启方式:在项目根目录运行 `.\env.ps1`(推荐)或 `.\env.cmd`。 + +--- + +## 三、项目结构 + +``` +7maHelper\ +├── SETUP.md ← 本文件(环境记录) +├── env.ps1 / env.cmd ← 会话环境变量脚本 +├── build.cmd ← 一键构建脚本 +├── settings.gradle +├── build.gradle ← 声明 AGP 版本 8.7.2 +├── gradle.properties ← jvmargs / androidX +├── local.properties ← sdk.dir 指向工作区 SDK +├── gradlew / gradlew.bat / gradle\wrapper\ ← Gradle Wrapper +├── android-sdk\ ← Android SDK(自包含) +├── tools\ ← Gradle 发行版 & 下载缓存 +└── app\ + ├── build.gradle ← 应用模块(无第三方依赖) + └── src\main\ + ├── AndroidManifest.xml ← 声明 MainActivity 与无障碍服务 + ├── java\com\example\accessibilityhelper\ + │ ├── MainActivity.java + │ └── MyAccessibilityService.java + └── res\ + ├── layout\activity_main.xml + ├── values\strings.xml / styles.xml + └── xml\accessibility_service_config.xml +``` + +--- + +## 四、构建与产物 + +- **构建命令(推荐)**:`.\build.cmd` + - 注:脚本已改为先 `cd /d "%BASE%"` 再执行 `gradle assembleDebug`,避免 `-p "<路径>"` 因路径结尾反斜杠导致引号被吞、Gradle 把 `assembleDebug` 误并入项目目录的问题。 +- **等同命令**: + ``` + $env:JAVA_HOME = "C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot" + $env:ANDROID_HOME = "c:\Users\Tan\Desktop\7maHelper\android-sdk" + c:\Users\Tan\Desktop\7maHelper\tools\gradle-8.10.2\bin\gradle.bat assembleDebug + ``` +- **产物 APK**:`app\build\outputs\apk\debug\app-debug.apk`(约 10.5 KB) + +--- + +## 五、安装到设备 / 调试 + +ADB 已在 `android-sdk\platform-tools\adb.exe`(或系统已有的 37.0.1): +``` +adb install -r app\build\outputs\apk\debug\app-debug.apk +adb shell am start -n com.example.accessibilityhelper/.MainActivity +``` +应用内点击按钮会跳转到系统「无障碍设置」,开启 `MyAccessibilityService` 后即生效。 + +--- + +## 六、功能说明(当前示例) + +- `MainActivity`:入口,点击按钮打开系统无障碍设置。 +- `MyAccessibilityService`:继承 `AccessibilityService`,在 `onAccessibilityEvent` 中可读取窗口内容、模拟点击等辅助操作(示例中为占位)。 +- 服务配置 `res/xml/accessibility_service_config.xml`:监听窗口状态/内容变化。 + +--- + +## 七、扩展建议(进阶) + +- 若需 AndroidX/Compose,可在 `app/build.gradle` 添加依赖并保留 `android.useAndroidX=true`。 +- 建议将 `android-sdk\` 与 `tools\` 加入 `.gitignore`,避免提交到版本库。 +- 卸载/清理:删除本目录即可(不含系统级安装的 JDK,JDK 卸载需用 winget 或控制面板)。 + +--- + +## 八、真机测试记录(2026-09-01) + +**设备**:V2403A(Vivo),Android 16(API 36,`getprop ro.build.version.sdk = 16`) +**连接**:`adb devices` → `10AE742Z7C00148 device` ✅ + +### 测试流程与结果 +| 步骤 | 命令 | 结果 | +|------|------|------| +| 1. 安装 APK | `adb install -r app\build\outputs\apk\debug\app-debug.apk` | ✅ Success | +| 2. 启动应用 | `adb shell am start -n com.example.accessibilityhelper/.MainActivity` | ✅ 主界面显示(标题「ASIST - 验证助手」+「打开无障碍设置」按钮) | +| 3. 无障碍设置 | 应用出现在系统「无障碍设置」的已下载应用列表 | ✅ 服务注册成功 | +| 4. 启用服务 | `adb shell settings put secure enabled_accessibility_services "com.example.accessibilityhelper/.MyAccessibilityService"` | ✅ `Enabled services` / `Bound services` 均包含本服务 | +| 5. 事件日志 | `adb logcat -d -s A11yHelper` | ⚠️ 见下方说明 | + +### 关键说明:关于事件日志为空 —— 其实是日志级别被过滤 +- 最初通过 adb `settings put` 启用服务后,`dumpsys accessibility` 显示服务**已绑定**且**无崩溃**,但 `logcat` 看不到 `A11yHelper` 日志。 +- **真正的根因**:该设备默认日志级别为 `log.tag = I`(INFO),会**屏蔽 `Log.d`(DEBUG 级)**。服务其实一直在正常接收事件,只是日志被隐藏了(logcat 缓冲里完全没有 `D` 级条目,`getprop log.tag` 显示 `[I]`)。 +- **解决办法(无需重编译)**:为指定标签单独开启 DEBUG 级: + ``` + adb shell setprop log.tag.A11yHelper D + ``` + 之后 `adb logcat -d -s A11yHelper` 即可看到事件日志。 +- 你也可以在代码里把 `Log.d` 改成 `Log.i`(INFO 级,默认就能看到)来规避此问题。 +- **已在真机验证到的事件**:`type=32`(TYPE_WINDOW_STATE_CHANGED)、`type=2048`(TYPE_WINDOW_CONTENT_CHANGED),来源包括 `com.android.settings`、`com.bbk.launcher2` 等。 +- 注意:Vivo ROM 建议在系统「无障碍设置」里**手动打开开关**(`settings put` 只是进入名单,未必真正分派)。 + +### 真机验证步骤(已跑通) +1. 打开应用 → 点「打开无障碍设置」;在系统无障碍设置中找到「ASIST - 验证助手」→ 打开开关并确认。 +2. `adb shell setprop log.tag.A11yHelper D`(仅影响当前会话,重启失效)。 +3. 切换任意 App/页面,`adb logcat -d -s A11yHelper` 即看到事件日志。 + +### 日志查看速查 +- 查看设备默认日志级别:`adb shell getprop log.tag` +- 临时开启 DEBUG:`adb shell setprop log.tag.A11yHelper D` diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..21df57a --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.example.accessibilityhelper' + compileSdk 35 + + defaultConfig { + applicationId "com.example.accessibilityhelper" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + // 离线中文 OCR(截图兜底),模型随 APK 打包,无需 Google Play 服务 + implementation 'com.google.mlkit:text-recognition-chinese:16.0.1' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6422f73 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/accessibilityhelper/EndRideReceiver.java b/app/src/main/java/com/example/accessibilityhelper/EndRideReceiver.java new file mode 100644 index 0000000..2385bff --- /dev/null +++ b/app/src/main/java/com/example/accessibilityhelper/EndRideReceiver.java @@ -0,0 +1,19 @@ +package com.example.accessibilityhelper; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * 处理「用车中」常驻通知上的「结束用车」按钮。 + * 收到广播后调用 RideSessionManager 结束计时并清除通知。 + */ +public class EndRideReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && RideSessionManager.ACTION_END_RIDE.equals(intent.getAction())) { + RideSessionManager.getInstance().end(); + } + } +} diff --git a/app/src/main/java/com/example/accessibilityhelper/MainActivity.java b/app/src/main/java/com/example/accessibilityhelper/MainActivity.java new file mode 100644 index 0000000..5016890 --- /dev/null +++ b/app/src/main/java/com/example/accessibilityhelper/MainActivity.java @@ -0,0 +1,185 @@ +package com.example.accessibilityhelper; + +import android.Manifest; +import android.app.Activity; +import android.app.NotificationManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.provider.Settings; +import android.widget.TextView; +import android.widget.Toast; + +/** + * 主界面: + * - 展示无障碍 / 通知 / 电池白名单等权限状态,点击可跳转开启; + * - 引导开启 Vivo 自启动,保障后台常驻守护; + * - 展示具体实现说明、安全性声明、免责声明与法律声明。 + */ +public class MainActivity extends Activity { + + private static final int REQ_NOTIFICATION_PERMISSION = 100; + + private TextView tvA11yStatus; + private TextView tvNotifStatus; + private TextView tvBatteryStatus; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + tvA11yStatus = findViewById(R.id.status_a11y); + tvNotifStatus = findViewById(R.id.status_notif); + tvBatteryStatus = findViewById(R.id.status_battery); + + findViewById(R.id.row_a11y).setOnClickListener(v -> { + try { + startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)); + } catch (Exception e) { + toast("无法打开无障碍设置"); + } + }); + + findViewById(R.id.row_notif).setOnClickListener(v -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + REQ_NOTIFICATION_PERMISSION); + } else { + try { + Intent i = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); + startActivity(i); + } catch (Exception e) { + toast("无法打开通知设置"); + } + } + }); + + findViewById(R.id.row_battery).setOnClickListener(v -> { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm != null && pm.isIgnoringBatteryOptimizations(getPackageName())) { + toast("已在电池优化白名单中"); + return; + } + try { + Intent i = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + getPackageName())); + startActivity(i); + } catch (Exception e) { + try { + startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)); + } catch (Exception e2) { + toast("无法打开电池优化设置"); + } + } + }); + + findViewById(R.id.row_autostart).setOnClickListener(v -> openVivoAutostart()); + + // Android 13+:首次进入申请通知权限 + requestNotificationPermissionIfNeeded(); + } + + @Override + protected void onResume() { + super.onResume(); + refreshStatuses(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + refreshStatuses(); + } + + private void refreshStatuses() { + setStatus(tvA11yStatus, isAccessibilityOn()); + setStatus(tvNotifStatus, isNotificationOn()); + setStatus(tvBatteryStatus, isBatteryWhitelisted()); + } + + private void setStatus(TextView tv, boolean on) { + tv.setText(on ? R.string.status_on : R.string.status_off); + tv.setBackgroundResource(on ? R.drawable.bg_chip_ok : R.drawable.bg_chip_bad); + tv.setTextColor(getColor(on ? R.color.ok_text : R.color.bad_text)); + } + + private boolean isAccessibilityOn() { + try { + int enabled = Settings.Secure.getInt( + getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED, 0); + if (enabled != 1) { + return false; + } + String services = Settings.Secure.getString( + getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); + String expect = getPackageName() + "/" + MyAccessibilityService.class.getName(); + return services != null && services.toLowerCase().contains(expect.toLowerCase()); + } catch (Exception e) { + return false; + } + } + + private boolean isNotificationOn() { + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null || !nm.areNotificationsEnabled()) { + return false; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + return true; + } + + private boolean isBatteryWhitelisted() { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + return pm != null && pm.isIgnoringBatteryOptimizations(getPackageName()); + } + + /** 打开 Vivo 自启动管理页(失败则给出提示) */ + private void openVivoAutostart() { + try { + Intent i = new Intent(); + i.setComponent(new ComponentName( + "com.vivo.permissionmanager", + "com.vivo.permissionmanager.activity.BgStartUpManagerActivity")); + startActivity(i); + return; + } catch (Exception ignored) { + } + try { + Intent i = new Intent(); + i.setComponent(new ComponentName( + "com.vivo.permissionmanager", + "com.vivo.permissionmanager.activity.PurviewTabActivity")); + startActivity(i); + return; + } catch (Exception ignored) { + } + toast("请在系统设置中搜索「自启动」,允许 4ma 自启动"); + } + + private void requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + REQ_NOTIFICATION_PERMISSION); + } + } + + private void toast(String msg) { + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/com/example/accessibilityhelper/MyAccessibilityService.java b/app/src/main/java/com/example/accessibilityhelper/MyAccessibilityService.java new file mode 100644 index 0000000..19ac59e --- /dev/null +++ b/app/src/main/java/com/example/accessibilityhelper/MyAccessibilityService.java @@ -0,0 +1,426 @@ +package com.example.accessibilityhelper; + +import android.accessibilityservice.AccessibilityService; +import android.content.Intent; +import android.graphics.Bitmap; +import android.hardware.HardwareBuffer; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; +import android.util.Log; +import android.view.Display; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import android.widget.Toast; + +import com.google.mlkit.vision.common.InputImage; +import com.google.mlkit.vision.text.TextRecognition; +import com.google.mlkit.vision.text.TextRecognizer; +import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions; + +/** + * 无障碍辅助服务。 + * + * 监听微信(com.tencent.mm)中「7MA出行」小程序的界面文字: + * - 匹配到「我要还车 / 预计费用 / 助力车编号」时开始用车计时; + * - 匹配到「还车成功」时自动结束计时。 + */ +public class MyAccessibilityService extends AccessibilityService { + + private static final String TAG = "A11yHelper"; + + /** 微信包名 */ + private static final String WECHAT_PKG = "com.tencent.mm"; + + private static final String KEY_RETURNING = "我要还车"; + private static final String KEY_ESTIMATE = "预计费用"; + private static final String KEY_VEHICLE_NO = "助力车编号"; + private static final String KEY_SUCCESS = "还车成功"; + + /** 两次无障碍扫描之间的最小间隔 */ + private static final long SCAN_THROTTLE_MS = 300L; + /** 节点树遍历最大深度 */ + private static final int MAX_DEPTH = 14; + + /** 骑行中 OCR 最小间隔:追着界面变化抓「还车成功」弹窗 */ + private static final long OCR_GAP_RIDING_MS = 600L; + /** 未骑行时 OCR 最小间隔:仅在界面变化事件期间运行 */ + private static final long OCR_GAP_IDLE_MS = 1000L; + /** 窗口状态切换(如弹窗出现)后补拍一帧的延迟 */ + private static final long FOLLOWUP_DELAY_MS = 800L; + + private long lastScanTime = 0L; + private long lastOcrTime = 0L; + private boolean ocrInProgress = false; + private boolean pendingOcr = false; + private boolean followupScheduled = false; + private TextRecognizer recognizer; + private final Handler handler = new Handler(Looper.getMainLooper()); + private final java.util.concurrent.Executor mainExecutor = command -> handler.post(command); + + @Override + public void onServiceConnected() { + super.onServiceConnected(); + RideSessionManager.getInstance().attach(this); + if (recognizer != null) { + recognizer.close(); + } + recognizer = TextRecognition.getClient(new ChineseTextRecognizerOptions.Builder().build()); + Toast.makeText(this, "无障碍服务已连接", Toast.LENGTH_SHORT).show(); + Log.i(TAG, "onServiceConnected: 服务已连接"); + } + + @Override + public void onDestroy() { + handler.removeCallbacksAndMessages(null); + if (recognizer != null) { + recognizer.close(); + recognizer = null; + } + RideSessionManager.getInstance().detach(this); + super.onDestroy(); + } + + /** 返回 true:配置变化或系统重启后自动重新绑定,配合后台常驻守护 */ + @Override + public boolean onUnbind(Intent intent) { + Log.i(TAG, "onUnbind: 保持可重新绑定"); + return true; + } + + @Override + public void onRebind(Intent intent) { + Log.i(TAG, "onRebind: 服务已重新绑定"); + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent event) { + if (event == null || event.getPackageName() == null) { + return; + } + String pkg = event.getPackageName().toString(); + int type = event.getEventType(); + // 诊断:无条件记录所有收到的无障碍事件,确认系统是否派发 + Log.i(TAG, "evt: pkg=" + pkg + " type=" + type); + if (!WECHAT_PKG.equals(pkg)) { + return; + } + if (type != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + && type != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastScanTime < SCAN_THROTTLE_MS) { + return; + } + lastScanTime = now; + scanWindow(event); + } + + /** + * 遍历当前激活窗口的节点树,收集文字,识别是否处于用车画面。 + */ + private void scanWindow(AccessibilityEvent event) { + StringBuilder sb = new StringBuilder(); + String rootDesc = ""; + + // 1) 优先从事件源节点读取(最贴近本次内容变化,尤其小程序 WebView 内容) + AccessibilityNodeInfo src = event != null ? event.getSource() : null; + if (src != null) { + src.refresh(); + collectText(src, sb, 0); + rootDesc = "eventSource=" + src.getPackageName() + "/" + src.getClassName() + + " children=" + src.getChildCount(); + src.recycle(); + } else { + rootDesc = "eventSource=null"; + } + + // 2) 事件源读不到,回退到激活窗口根 + if (sb.length() == 0) { + AccessibilityNodeInfo root = getRootInActiveWindow(); + if (root != null) { + root.refresh(); + collectText(root, sb, 0); + rootDesc += " root=" + root.getPackageName() + "/" + root.getClassName() + + " children=" + root.getChildCount(); + root.recycle(); + } else { + rootDesc += " root=null"; + } + } + + // 3) 仍读不到,遍历所有窗口,但只读目标包(微信)窗口,跳过状态栏/系统栏 + if (sb.length() == 0) { + boolean found = false; + for (AccessibilityWindowInfo w : getWindows()) { + if (w == null) { + continue; + } + AccessibilityNodeInfo r = w.getRoot(); + if (r == null || r.getPackageName() == null) { + continue; + } + String wpkg = r.getPackageName().toString(); + if (!WECHAT_PKG.equals(wpkg)) { + continue; + } + int beforeLen = sb.length(); + r.refresh(); + collectText(r, sb, 0); + rootDesc += " getWindows(pkg=" + wpkg + " children=" + r.getChildCount() + ")"; + r.recycle(); + if (sb.length() > beforeLen) { + found = true; + break; + } + } + if (!found) { + rootDesc += " getWindows:(no wechat content)"; + } + } + + String text = sb.toString(); + + String preview = text.length() > 200 ? text.substring(0, 200) : text; + Log.i(TAG, "scan: " + rootDesc + + " len=" + text.length() + + " 还车成功=" + text.contains(KEY_SUCCESS) + + " 我要还车=" + text.contains(KEY_RETURNING) + + " 预计费用=" + text.contains(KEY_ESTIMATE) + + " 助力车编号=" + text.contains(KEY_VEHICLE_NO) + + " text=[" + preview + "]"); + + // 诊断:读不到内容时,dump 事件源与激活窗口的节点树 + if (text.length() == 0) { + AccessibilityNodeInfo dbgSrc = event != null ? event.getSource() : null; + if (dbgSrc != null) { + Log.i(TAG, "TREE(eventSource): " + dumpNodeTree(dbgSrc, 0)); + dbgSrc.recycle(); + } + AccessibilityNodeInfo root = getRootInActiveWindow(); + if (root != null) { + Log.i(TAG, "TREE(root): " + dumpNodeTree(root, 0)); + root.recycle(); + } + } + + boolean hasSuccess = text.contains(KEY_SUCCESS); + boolean hasStart = text.contains(KEY_RETURNING) + || text.contains(KEY_ESTIMATE) + || text.contains(KEY_VEHICLE_NO); + if (hasSuccess) { + Log.i(TAG, "hit: 还车成功 -> end"); + RideSessionManager.getInstance().end(); + } else if (hasStart) { + Log.i(TAG, "hit: 用车界面 -> start"); + RideSessionManager.getInstance().start(); + } else if (text.isEmpty() || RideSessionManager.getInstance().isRiding()) { + // 无障碍读不到内容(WebView 未暴露)或骑行中需盯「还车成功」: + // 仅在微信界面产生变化事件时做截图+OCR,界面静止即停止,不在后台持续识别 + boolean stateChanged = event != null + && event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED; + scheduleOcr(stateChanged); + } + } + + /** 节点树 dump(类名/文字),用于诊断小程序内容是否可读 */ + private String dumpNodeTree(AccessibilityNodeInfo node, int depth) { + if (node == null || depth > 8) { + return ""; + } + StringBuilder sb = new StringBuilder(); + CharSequence t = node.getText(); + CharSequence d = node.getContentDescription(); + sb.append("\n").append(" ".repeat(Math.min(depth, 6))) + .append(node.getClassName()).append(" id=").append(node.getViewIdResourceName()); + if (t != null) { + sb.append(" text=").append(t); + } + if (d != null) { + sb.append(" desc=").append(d); + } + for (int i = 0; i < node.getChildCount(); i++) { + sb.append(dumpNodeTree(node.getChild(i), depth + 1)); + } + return sb.toString(); + } + + private void collectText(AccessibilityNodeInfo node, StringBuilder sb, int depth) { + if (node == null || depth > MAX_DEPTH) { + return; + } + CharSequence text = node.getText(); + if (text != null && text.length() > 0) { + sb.append(text).append(' '); + } + CharSequence desc = node.getContentDescription(); + if (desc != null && desc.length() > 0) { + sb.append(desc).append(' '); + } + for (int i = 0; i < node.getChildCount(); i++) { + collectText(node.getChild(i), sb, depth + 1); + } + } + + /** + * OCR 兜底(事件驱动):只在微信界面产生变化事件时运行, + * 界面静止时完全停止。骑行中节奏提到 ~600ms/帧,捕捉一闪而过的「还车成功」。 + */ + private void scheduleOcr(boolean stateChanged) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return; + } + if (recognizer == null) { + return; + } + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm != null && !pm.isInteractive()) { + return; // 息屏不识别,省电 + } + long now = System.currentTimeMillis(); + long minGap = RideSessionManager.getInstance().isRiding() + ? OCR_GAP_RIDING_MS : OCR_GAP_IDLE_MS; + if (ocrInProgress || now - lastOcrTime < minGap) { + pendingOcr = true; // 等当前识别完成后立刻补一帧 + if (stateChanged) { + scheduleFollowup(); + } + return; + } + lastOcrTime = now; + doOcr(); + if (stateChanged) { + scheduleFollowup(); + } + } + + /** 窗口状态切换(弹窗/页面切换)后延迟补拍一帧,捕捉短暂提示 */ + private void scheduleFollowup() { + if (followupScheduled) { + return; + } + followupScheduled = true; + handler.postDelayed(() -> { + followupScheduled = false; + long now = System.currentTimeMillis(); + long minGap = RideSessionManager.getInstance().isRiding() + ? OCR_GAP_RIDING_MS : OCR_GAP_IDLE_MS; + if (ocrInProgress || now - lastOcrTime < minGap) { + pendingOcr = true; + return; + } + lastOcrTime = now; + doOcr(); + }, FOLLOWUP_DELAY_MS); + } + + private void doOcr() { + ocrInProgress = true; + try { + takeScreenshot(Display.DEFAULT_DISPLAY, mainExecutor, new TakeScreenshotCallback() { + @Override + public void onSuccess(ScreenshotResult result) { + HardwareBuffer buffer = result.getHardwareBuffer(); + Bitmap toProcess = null; + try { + Bitmap raw = Bitmap.wrapHardwareBuffer(buffer, result.getColorSpace()); + if (raw != null) { + toProcess = raw.copy(Bitmap.Config.ARGB_8888, false); + } + } catch (Throwable t) { + Log.i(TAG, "wrapHardwareBuffer failed: " + t); + } finally { + buffer.close(); + } + if (toProcess == null) { + ocrInProgress = false; + return; + } + final Bitmap scaled = scaleDown(toProcess); + if (scaled != toProcess) { + toProcess.recycle(); + } + try { + InputImage image = InputImage.fromBitmap(scaled, 0); + recognizer.process(image) + .addOnSuccessListener(text -> { + String s = text.getText(); + String preview = s.length() > 120 ? s.substring(0, 120) : s; + Log.i(TAG, "ocr: len=" + s.length() + " text=[" + preview + "]"); + evaluateText(s); + }) + .addOnFailureListener(e -> Log.i(TAG, "ocr failed: " + e)) + .addOnCompleteListener(task -> { + scaled.recycle(); + ocrInProgress = false; + drainPendingOcr(); + }); + } catch (Throwable t) { + Log.i(TAG, "ocr exception: " + t); + scaled.recycle(); + ocrInProgress = false; + drainPendingOcr(); + } + } + + @Override + public void onFailure(int errorCode) { + Log.i(TAG, "takeScreenshot failed, code=" + errorCode); + ocrInProgress = false; + drainPendingOcr(); + } + }); + } catch (Throwable t) { + Log.i(TAG, "takeScreenshot exception: " + t); + ocrInProgress = false; + } + } + + /** 有事件积压时补跑一帧,直到界面安静下来 */ + private void drainPendingOcr() { + if (!pendingOcr) { + return; + } + pendingOcr = false; + long now = System.currentTimeMillis(); + long minGap = RideSessionManager.getInstance().isRiding() + ? OCR_GAP_RIDING_MS : OCR_GAP_IDLE_MS; + if (now - lastOcrTime >= minGap) { + lastOcrTime = now; + doOcr(); + } + } + + private void evaluateText(String text) { + if (text == null || text.isEmpty()) { + return; + } + if (text.contains(KEY_SUCCESS)) { + RideSessionManager.getInstance().end(); + } else if (text.contains(KEY_RETURNING) + || text.contains(KEY_ESTIMATE) + || text.contains(KEY_VEHICLE_NO)) { + RideSessionManager.getInstance().start(); + } + } + + /** 缩小截图,降低 OCR 耗时与内存 */ + private static Bitmap scaleDown(Bitmap src) { + final int maxW = 1080; + if (src.getWidth() <= maxW) { + return src; + } + float scale = (float) maxW / src.getWidth(); + int w = Math.max(1, Math.round(src.getWidth() * scale)); + int h = Math.max(1, Math.round(src.getHeight() * scale)); + return Bitmap.createScaledBitmap(src, w, h, true); + } + + @Override + public void onInterrupt() { + Log.i(TAG, "onInterrupt"); + } +} diff --git a/app/src/main/java/com/example/accessibilityhelper/RideSessionManager.java b/app/src/main/java/com/example/accessibilityhelper/RideSessionManager.java new file mode 100644 index 0000000..c0cdf8d --- /dev/null +++ b/app/src/main/java/com/example/accessibilityhelper/RideSessionManager.java @@ -0,0 +1,234 @@ +package com.example.accessibilityhelper; + +import android.accessibilityservice.AccessibilityService; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.widget.Toast; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 用车计时会话管理器(单例)。 + * + * 职责: + * - 在匹配到「我要还车/预计费用/助力车编号」时开始计时; + * - 构建并更新系统通知栏的「用车中」常驻通知(含手动「结束用车」按钮); + * - 到达 15 / 18 / 30 分钟时,各发送一条普通提醒通知; + * - 在匹配到「还车成功」或用户点击「结束用车」时结束计时,并清除通知。 + */ +public class RideSessionManager { + + private static final String TAG = "RideSession"; + + /** 常驻通知渠道 */ + public static final String CHANNEL_RIDE = "ride_ongoing"; + /** 提醒通知渠道 */ + public static final String CHANNEL_REMIND = "ride_reminder"; + + private static final int NOTIF_ID_ONGOING = 1001; + private static final int REMIND_BASE_ID = 2000; + + /** 里程碑提醒(分钟) */ + private static final int[] MILESTONE_MINUTES = {15, 18, 30}; + + /** 结束用车动作 */ + public static final String ACTION_END_RIDE = "com.example.accessibilityhelper.ACTION_END_RIDE"; + + private static RideSessionManager instance; + + private AccessibilityService service; + private NotificationManager nm; + private Handler handler; + + private boolean riding = false; + private long startWallTime = 0L; + private long startElapsedRealtime = 0L; + private final boolean[] fired = new boolean[MILESTONE_MINUTES.length]; + + private RideSessionManager() { + } + + public static synchronized RideSessionManager getInstance() { + if (instance == null) { + instance = new RideSessionManager(); + } + return instance; + } + + /** 由无障碍服务在 onServiceConnected 时调用 */ + public void attach(AccessibilityService s) { + this.service = s; + this.nm = (NotificationManager) s.getSystemService(Context.NOTIFICATION_SERVICE); + this.handler = new Handler(Looper.getMainLooper()); + createChannels(); + // 清除上一进程残留的通知(进程被杀后常驻通知不会自动消失) + nm.cancel(NOTIF_ID_ONGOING); + for (int m : MILESTONE_MINUTES) { + nm.cancel(REMIND_BASE_ID + m); + } + } + + /** 由无障碍服务在 onDestroy 时调用:停止计时并清除通知,避免残留常驻通知 */ + public void detach(AccessibilityService s) { + if (service == s) { + riding = false; + if (handler != null) { + handler.removeCallbacksAndMessages(null); + } + if (nm != null) { + nm.cancel(NOTIF_ID_ONGOING); + for (int m : MILESTONE_MINUTES) { + nm.cancel(REMIND_BASE_ID + m); + } + } + service = null; + } + } + + private void createChannels() { + if (nm == null) { + return; + } + NotificationChannel ride = new NotificationChannel( + CHANNEL_RIDE, "用车状态", NotificationManager.IMPORTANCE_LOW); + ride.setDescription("用车中的常驻通知"); + ride.setSound(null, null); + nm.createNotificationChannel(ride); + + NotificationChannel remind = new NotificationChannel( + CHANNEL_REMIND, "用车提醒", NotificationManager.IMPORTANCE_HIGH); + remind.setDescription("用车计时到点提醒"); + nm.createNotificationChannel(remind); + } + + public synchronized boolean isRiding() { + return riding; + } + + /** 开始计时 */ + public synchronized void start() { + if (riding || service == null) { + return; + } + riding = true; + startWallTime = System.currentTimeMillis(); + startElapsedRealtime = SystemClock.elapsedRealtime(); + for (int i = 0; i < fired.length; i++) { + fired[i] = false; + } + postOngoing(); + scheduleTick(); + toast("开始用车计时"); + } + + /** 结束计时 */ + public synchronized void end() { + if (!riding) { + return; + } + riding = false; + if (handler != null) { + handler.removeCallbacksAndMessages(null); + } + if (nm != null) { + nm.cancel(NOTIF_ID_ONGOING); + for (int m : MILESTONE_MINUTES) { + nm.cancel(REMIND_BASE_ID + m); + } + } + toast("已结束用车,并清除通知"); + } + + private void scheduleTick() { + if (!riding || handler == null) { + return; + } + handler.postDelayed(tick, 1000L); + } + + private final Runnable tick = new Runnable() { + @Override + public void run() { + if (!riding) { + return; + } + long elapsedMs = SystemClock.elapsedRealtime() - startElapsedRealtime; + long seconds = elapsedMs / 1000L; + checkMilestones(seconds); + scheduleTick(); + } + }; + + private void checkMilestones(long seconds) { + if (nm == null) { + return; + } + for (int i = 0; i < MILESTONE_MINUTES.length; i++) { + if (!fired[i] && seconds >= MILESTONE_MINUTES[i] * 60L) { + fired[i] = true; + postReminder(MILESTONE_MINUTES[i]); + } + } + } + + /** 构建/更新「用车中」常驻通知 */ + private void postOngoing() { + if (service == null || nm == null) { + return; + } + Notification.Builder b = buildOngoing(); + nm.notify(NOTIF_ID_ONGOING, b.build()); + } + + private Notification.Builder buildOngoing() { + Intent endIntent = new Intent(service, EndRideReceiver.class); + endIntent.setAction(ACTION_END_RIDE); + PendingIntent endPi = PendingIntent.getBroadcast( + service, 1, endIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + String startText = "开始于 " + formatWallTime(startWallTime); + return new Notification.Builder(service, CHANNEL_RIDE) + .setContentTitle("用车中") + .setContentText(startText) + .setSmallIcon(R.drawable.ic_stat_ride) + .setOngoing(true) + .setWhen(startWallTime) + .setUsesChronometer(true) + .setOnlyAlertOnce(true) + .setShowWhen(true) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "结束用车", endPi); + } + + /** 到达里程碑时的普通提醒通知 */ + private void postReminder(int minutes) { + if (service == null || nm == null) { + return; + } + Notification.Builder b = new Notification.Builder(service, CHANNEL_REMIND) + .setContentTitle("用车提醒") + .setContentText("已用车 " + minutes + " 分钟,请注意用车时长") + .setSmallIcon(R.drawable.ic_stat_ride) + .setAutoCancel(true); + nm.notify(REMIND_BASE_ID + minutes, b.build()); + } + + private static String formatWallTime(long timeMillis) { + return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(new Date(timeMillis)); + } + + private void toast(String msg) { + if (service != null) { + Toast.makeText(service, msg, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/res/drawable/bg_btn_outline.xml b/app/src/main/res/drawable/bg_btn_outline.xml new file mode 100644 index 0000000..27100a0 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_outline.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..625b470 --- /dev/null +++ b/app/src/main/res/drawable/bg_card.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_chip_bad.xml b/app/src/main/res/drawable/bg_chip_bad.xml new file mode 100644 index 0000000..f9858a3 --- /dev/null +++ b/app/src/main/res/drawable/bg_chip_bad.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_chip_ok.xml b/app/src/main/res/drawable/bg_chip_ok.xml new file mode 100644 index 0000000..55baf12 --- /dev/null +++ b/app/src/main/res/drawable/bg_chip_ok.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_stat_ride.xml b/app/src/main/res/drawable/ic_stat_ride.xml new file mode 100644 index 0000000..2eff1a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_ride.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e7eb68f --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,395 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..56641bc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..949ba76 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0ce7395 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ede174a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..920f143 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d9f9ea1 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,16 @@ + + + + #F7F9FB + #FFFFFF + #1F2937 + #6B7280 + #9CA3AF + #10B981 + #059669 + #D1FAE5 + #047857 + #FEE2E2 + #B91C1C + #E5E9EE + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2210765 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,46 @@ + + + + 4ma + 7ma还车提醒工具 + + + 4ma + 7ma还车提醒工具:监听7MA出行小程序用车界面,自动开始/结束用车计时,并在15/18/30分钟提醒。仅读取界面文字,不执行任何点击或模拟操作。 + + + 4ma + 7ma还车提醒工具 + 监听 7MA 出行小程序 · 自动计时 · 到点提醒 · 全程离线 + + 权限与后台守护 + + 无障碍服务 + 读取用车界面文字,自动开始/结束计时 + 通知权限 + 显示计时常驻通知与 15/18/30 分钟提醒 + 电池优化白名单 + 防止系统在后台冻结计时服务 + 自启动权限 + 开机或清理后台后自动恢复守护 + + 已开启 + 未开启 + 去设置 + + 守护说明:无障碍服务由系统持续绑定,开启后即使重启手机也会自动恢复。建议同时开启电池白名单与自启动,避免国产系统在后台冻结本应用导致计时中断。 + + 具体实现 + 本应用通过 Android 无障碍服务监听微信(com.tencent.mm)中的「7MA出行」小程序:检测到「我要还车 / 预计费用 / 助力车编号」等界面文字时自动开始用车计时;检测到「还车成功」时自动结束。计时期间通知栏常驻显示用车时长,并在 15 / 18 / 30 分钟各提醒一次;也可以直接点击通知上的「结束用车」按钮手动结束。\n\n当微信小程序的无障碍节点树无法读取内容(WebView 内核限制)时,应用会调用系统无障碍截图接口,并使用内置的离线中文文字识别(ML Kit)匹配上述关键词。全部识别均在手机本地完成,不联网。 + + 安全性声明 + · 本应用不申请网络权限,完全离线运行。\n· 不收集、不存储、不上传任何个人信息与使用数据。\n· 无障碍服务仅用于读取「7MA出行」小程序界面文字并匹配关键词,不模拟点击、不修改或转发任何界面内容。\n· 截图仅在识别瞬间生成,识别完成立即释放内存,不保存任何图片。 + + 免责声明 + 本工具为非官方第三方辅助工具,与「7MA出行」官方及腾讯微信无任何关联。计时与提醒结果仅供参考,不构成任何费用计算依据,实际用车时长与费用请以 7MA 出行官方账单为准。因手机系统限制、微信或小程序版本更新、无障碍服务被系统终止等原因导致的检测失败或提醒遗漏,开发者不承担由此产生的任何费用、损失或责任。 + + 法律声明 + 本应用仅限个人学习与出行辅助用途。使用时请遵守中华人民共和国法律法规及 7MA 出行平台的服务协议与相关规则。禁止将本应用用于任何商业用途,或以任何方式侵害 7MA 出行、腾讯及第三方的合法权益。如平台规则变化,请立即停止使用。 + + v1.0 · 非 7MA 出行官方应用 · 请合规使用 + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d9e1c62 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..6376273 --- /dev/null +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,10 @@ + + diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..63cbd23 --- /dev/null +++ b/build.cmd @@ -0,0 +1,18 @@ +@echo off +REM 便捷构建脚本:使用工作区内的 Gradle 发行版构建 Debug APK +setlocal + +set "BASE=%~dp0" +set "JAVA_HOME=C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot" +set "ANDROID_HOME=%BASE%android-sdk" + +set "GRADLE=%BASE%tools\gradle-8.10.2\bin\gradle.bat" +if not exist "%GRADLE%" ( + echo [ERROR] Gradle 未找到: %GRADLE% + exit /b 1 +) + +echo 使用 Gradle: %GRADLE% +cd /d "%BASE%" +call "%GRADLE%" assembleDebug +endlocal diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..b09a548 --- /dev/null +++ b/build.gradle @@ -0,0 +1,4 @@ +// 根构建脚本:声明 Android Gradle Plugin 版本,供子模块应用 +plugins { + id 'com.android.application' version '8.7.2' apply false +} diff --git a/env.cmd b/env.cmd new file mode 100644 index 0000000..880256e --- /dev/null +++ b/env.cmd @@ -0,0 +1,9 @@ +@echo off +REM 为当前 cmd 会话设置 Android 开发环境变量(免重启) +set "JAVA_HOME=C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot" +set "ANDROID_HOME=C:\Users\Tan\Desktop\7maHelper\android-sdk" +set "ANDROID_SDK_ROOT=%ANDROID_HOME%" +set "PATH=%JAVA_HOME%\bin;%PATH%" +set "PATH=%ANDROID_HOME%\platform-tools;%PATH%" +set "PATH=%ANDROID_HOME%\cmdline-tools\latest\bin;%PATH%" +echo JAVA_HOME set, ANDROID_HOME set, PATH updated. diff --git a/env.ps1 b/env.ps1 new file mode 100644 index 0000000..0cc1586 --- /dev/null +++ b/env.ps1 @@ -0,0 +1,14 @@ +# 为当前 PowerShell 会话设置 Android 开发环境变量(免重启) +# 用法:在项目根目录执行 .\env.ps1 + +$env:JAVA_HOME = "C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot" +$env:ANDROID_HOME = "C:\Users\Tan\Desktop\7maHelper\android-sdk" +$env:ANDROID_SDK_ROOT = $env:ANDROID_HOME + +$env:Path = "$env:JAVA_HOME\bin;$env:PATH" +$env:Path = "$env:ANDROID_HOME\platform-tools;$env:Path" +$env:Path = "$env:ANDROID_HOME\cmdline-tools\latest\bin;$env:Path" + +Write-Host "JAVA_HOME = $env:JAVA_HOME" +Write-Host "ANDROID_HOME = $env:ANDROID_HOME" +Write-Host "PATH 已加入 java / adb / sdkmanager" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3c5e113 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..5990dd8 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "AccessibilityHelper" +include ':app' diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..12e71e3 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,79 @@ +# 生成 4ma 应用图标(PNG,各密度 mipmap) +# 设计:浅色圆角底板 + 绿色圆角方块 + 白色加粗 "4ma" +import os +from PIL import Image, ImageDraw, ImageFont + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RES = os.path.join(BASE, "app", "src", "main", "res") + +SIZES = { + "mipmap-mdpi": 48, + "mipmap-hdpi": 72, + "mipmap-xhdpi": 96, + "mipmap-xxhdpi": 144, + "mipmap-xxxhdpi": 192, +} + +MASTER = 1024 +BG = (242, 247, 251, 255) # 浅色底板 +TILE = (16, 185, 129, 255) # 品牌绿 #10B981 +TILE_DARK = (5, 150, 105, 255) # 深绿 #059669 +WHITE = (255, 255, 255, 255) + + +def load_font(size): + for name in ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"): + for root in (os.environ.get("WINDIR", "C:/Windows") + "/Fonts", "/usr/share/fonts/truetype"): + p = os.path.join(root, name) + if os.path.exists(p): + try: + return ImageFont.truetype(p, size) + except Exception: + continue + return ImageFont.load_default() + + +def make_master(): + img = Image.new("RGBA", (MASTER, MASTER), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + + # 底板:整幅浅色(launcher 会自行裁形) + d.rectangle([0, 0, MASTER, MASTER], fill=BG) + + # 绿色圆角方块(留 16% 边距) + m = int(MASTER * 0.16) + d.rounded_rectangle([m, m, MASTER - m, MASTER - m], + radius=int(MASTER * 0.19), fill=TILE) + + # 底部 1/4 叠加深绿,做出轻微渐变感 + overlay = Image.new("RGBA", (MASTER, MASTER), (0, 0, 0, 0)) + od = ImageDraw.Draw(overlay) + od.rounded_rectangle([m, MASTER - int(MASTER * 0.30), MASTER - m, MASTER - m], + radius=int(MASTER * 0.19), fill=TILE_DARK) + img = Image.alpha_composite(img, overlay) + d = ImageDraw.Draw(img) + + # 白色加粗 "4ma" + font = load_font(int(MASTER * 0.40)) + text = "4ma" + bb = d.textbbox((0, 0), text, font=font) + w = bb[2] - bb[0] + h = bb[3] - bb[1] + x = (MASTER - w) / 2 - bb[0] + y = (MASTER - h) / 2 - bb[1] + d.text((x, y), text, font=font, fill=WHITE) + return img + + +def main(): + master = make_master() + for folder, size in SIZES.items(): + out_dir = os.path.join(RES, folder) + os.makedirs(out_dir, exist_ok=True) + out = os.path.join(out_dir, "ic_launcher.png") + master.resize((size, size), Image.LANCZOS).save(out) + print("written:", out) + + +if __name__ == "__main__": + main()