feat: 4ma 用车计时助手 v1.0
- 无障碍服务监听7MA出行小程序,匹配关键词自动开始/结束计时 - 事件驱动截图+ML Kit离线中文OCR兜底,解决WebView无障碍树不稳定 - 常驻计时通知(15/18/30分钟提醒)与结束用车按钮 - 浅色主题主界面:权限状态、具体实现、安全/免责/法律声明 - 电池白名单+自启动引导+onUnbind自动重绑守护 - 无网络权限,完全离线运行
This commit is contained in:
commit
2cf3cbad56
34 files changed
+2158
No files matched your search
+30
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Android 13+ 需要通知运行时权限 -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- 请求加入电池优化白名单(后台守护) -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<!-- 移除 ML Kit 传递依赖带入的网络权限,保证完全离线 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<!-- 入口 Activity:用于打开系统无障碍设置 -->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- 无障碍服务声明 -->
|
||||||
|
<service
|
||||||
|
android:name=".MyAccessibilityService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/accessibility_service_label"
|
||||||
|
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/accessibility_service_config" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- 通知栏「结束用车」按钮的广播接收器 -->
|
||||||
|
<receiver
|
||||||
|
android:name=".EndRideReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#00000000" />
|
||||||
|
<corners android:radius="999dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#D1D5DB" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/card" />
|
||||||
|
<corners android:radius="14dp" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/border" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/bad_bg" />
|
||||||
|
<corners android:radius="999dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/ok_bg" />
|
||||||
|
<corners android:radius="999dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 通知栏小图标:白色时钟(纯 alpha 图形) -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,12 m-8.5,0 a8.5,8.5 0 1,1 17,0 a8.5,8.5 0 1,1 -17,0"
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M12,12 L12,6.2"
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M12,12 L15.4,13.9"
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg"
|
||||||
|
android:fillViewport="true"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingTop="30dp"
|
||||||
|
android:paddingBottom="40dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/header_title"
|
||||||
|
android:textColor="@color/text_main"
|
||||||
|
android:textSize="32sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/header_desc"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:text="@string/header_sub"
|
||||||
|
android:textColor="@color/text_faint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<!-- 权限与后台守护 -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="26dp"
|
||||||
|
android:text="@string/sec_permissions"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/bg_card"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/row_a11y"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/row_a11y"
|
||||||
|
android:textColor="@color/text_main"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/row_a11y_desc"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_a11y"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_chip_bad"
|
||||||
|
android:paddingStart="10dp"
|
||||||
|
android:paddingEnd="10dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:text="@string/status_off"
|
||||||
|
android:textColor="@color/bad_text"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:background="@color/border" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/row_notif"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/row_notif"
|
||||||
|
android:textColor="@color/text_main"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/row_notif_desc"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_notif"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_chip_bad"
|
||||||
|
android:paddingStart="10dp"
|
||||||
|
android:paddingEnd="10dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:text="@string/status_off"
|
||||||
|
android:textColor="@color/bad_text"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:background="@color/border" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/row_battery"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/row_battery"
|
||||||
|
android:textColor="@color/text_main"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/row_battery_desc"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_battery"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_chip_bad"
|
||||||
|
android:paddingStart="10dp"
|
||||||
|
android:paddingEnd="10dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:text="@string/status_off"
|
||||||
|
android:textColor="@color/bad_text"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:background="@color/border" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/row_autostart"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/row_autostart"
|
||||||
|
android:textColor="@color/text_main"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/row_autostart_desc"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_btn_outline"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="5dp"
|
||||||
|
android:paddingBottom="5dp"
|
||||||
|
android:text="@string/status_goto"
|
||||||
|
android:textColor="@color/text_main"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="#F5F8FA"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:text="@string/tip_daemon"
|
||||||
|
android:textColor="@color/text_faint"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 具体实现 -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:text="@string/sec_impl_title"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/bg_card"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:text="@string/sec_impl_body"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 安全性声明 -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:text="@string/sec_security_title"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/bg_card"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:text="@string/sec_security_body"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 免责声明 -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:text="@string/sec_disclaimer_title"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/bg_card"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:text="@string/sec_disclaimer_body"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 法律声明 -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:text="@string/sec_legal_title"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/bg_card"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:text="@string/sec_legal_body"
|
||||||
|
android:textColor="@color/text_sub"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="28dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/footer_text"
|
||||||
|
android:textColor="@color/text_faint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 浅色主题色板 -->
|
||||||
|
<color name="bg">#F7F9FB</color>
|
||||||
|
<color name="card">#FFFFFF</color>
|
||||||
|
<color name="text_main">#1F2937</color>
|
||||||
|
<color name="text_sub">#6B7280</color>
|
||||||
|
<color name="text_faint">#9CA3AF</color>
|
||||||
|
<color name="brand">#10B981</color>
|
||||||
|
<color name="brand_dark">#059669</color>
|
||||||
|
<color name="ok_bg">#D1FAE5</color>
|
||||||
|
<color name="ok_text">#047857</color>
|
||||||
|
<color name="bad_bg">#FEE2E2</color>
|
||||||
|
<color name="bad_text">#B91C1C</color>
|
||||||
|
<color name="border">#E5E9EE</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 应用信息 -->
|
||||||
|
<string name="app_name">4ma</string>
|
||||||
|
<string name="app_desc">7ma还车提醒工具</string>
|
||||||
|
|
||||||
|
<!-- 无障碍服务 -->
|
||||||
|
<string name="accessibility_service_label">4ma</string>
|
||||||
|
<string name="accessibility_service_description">7ma还车提醒工具:监听7MA出行小程序用车界面,自动开始/结束用车计时,并在15/18/30分钟提醒。仅读取界面文字,不执行任何点击或模拟操作。</string>
|
||||||
|
|
||||||
|
<!-- 主界面 -->
|
||||||
|
<string name="header_title">4ma</string>
|
||||||
|
<string name="header_desc">7ma还车提醒工具</string>
|
||||||
|
<string name="header_sub">监听 7MA 出行小程序 · 自动计时 · 到点提醒 · 全程离线</string>
|
||||||
|
|
||||||
|
<string name="sec_permissions">权限与后台守护</string>
|
||||||
|
|
||||||
|
<string name="row_a11y">无障碍服务</string>
|
||||||
|
<string name="row_a11y_desc">读取用车界面文字,自动开始/结束计时</string>
|
||||||
|
<string name="row_notif">通知权限</string>
|
||||||
|
<string name="row_notif_desc">显示计时常驻通知与 15/18/30 分钟提醒</string>
|
||||||
|
<string name="row_battery">电池优化白名单</string>
|
||||||
|
<string name="row_battery_desc">防止系统在后台冻结计时服务</string>
|
||||||
|
<string name="row_autostart">自启动权限</string>
|
||||||
|
<string name="row_autostart_desc">开机或清理后台后自动恢复守护</string>
|
||||||
|
|
||||||
|
<string name="status_on">已开启</string>
|
||||||
|
<string name="status_off">未开启</string>
|
||||||
|
<string name="status_goto">去设置</string>
|
||||||
|
|
||||||
|
<string name="tip_daemon">守护说明:无障碍服务由系统持续绑定,开启后即使重启手机也会自动恢复。建议同时开启电池白名单与自启动,避免国产系统在后台冻结本应用导致计时中断。</string>
|
||||||
|
|
||||||
|
<string name="sec_impl_title">具体实现</string>
|
||||||
|
<string name="sec_impl_body">本应用通过 Android 无障碍服务监听微信(com.tencent.mm)中的「7MA出行」小程序:检测到「我要还车 / 预计费用 / 助力车编号」等界面文字时自动开始用车计时;检测到「还车成功」时自动结束。计时期间通知栏常驻显示用车时长,并在 15 / 18 / 30 分钟各提醒一次;也可以直接点击通知上的「结束用车」按钮手动结束。\n\n当微信小程序的无障碍节点树无法读取内容(WebView 内核限制)时,应用会调用系统无障碍截图接口,并使用内置的离线中文文字识别(ML Kit)匹配上述关键词。全部识别均在手机本地完成,不联网。</string>
|
||||||
|
|
||||||
|
<string name="sec_security_title">安全性声明</string>
|
||||||
|
<string name="sec_security_body">· 本应用不申请网络权限,完全离线运行。\n· 不收集、不存储、不上传任何个人信息与使用数据。\n· 无障碍服务仅用于读取「7MA出行」小程序界面文字并匹配关键词,不模拟点击、不修改或转发任何界面内容。\n· 截图仅在识别瞬间生成,识别完成立即释放内存,不保存任何图片。</string>
|
||||||
|
|
||||||
|
<string name="sec_disclaimer_title">免责声明</string>
|
||||||
|
<string name="sec_disclaimer_body">本工具为非官方第三方辅助工具,与「7MA出行」官方及腾讯微信无任何关联。计时与提醒结果仅供参考,不构成任何费用计算依据,实际用车时长与费用请以 7MA 出行官方账单为准。因手机系统限制、微信或小程序版本更新、无障碍服务被系统终止等原因导致的检测失败或提醒遗漏,开发者不承担由此产生的任何费用、损失或责任。</string>
|
||||||
|
|
||||||
|
<string name="sec_legal_title">法律声明</string>
|
||||||
|
<string name="sec_legal_body">本应用仅限个人学习与出行辅助用途。使用时请遵守中华人民共和国法律法规及 7MA 出行平台的服务协议与相关规则。禁止将本应用用于任何商业用途,或以任何方式侵害 7MA 出行、腾讯及第三方的合法权益。如平台规则变化,请立即停止使用。</string>
|
||||||
|
|
||||||
|
<string name="footer_text">v1.0 · 非 7MA 出行官方应用 · 请合规使用</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 浅色主题 -->
|
||||||
|
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@color/bg</item>
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:navigationBarColor">@color/bg</item>
|
||||||
|
<item name="android:windowBackground">@color/bg</item>
|
||||||
|
<item name="android:colorAccent">@color/brand</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accessibilityEventTypes="typeWindowContentChanged|typeWindowStateChanged"
|
||||||
|
android:accessibilityFeedbackType="feedbackAllMask"
|
||||||
|
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds|flagIncludeNotImportantViews|flagRequestEnhancedWebAccessibility"
|
||||||
|
android:notificationTimeout="100"
|
||||||
|
android:canPerformGestures="true"
|
||||||
|
android:canRetrieveWindowContent="true"
|
||||||
|
android:canTakeScreenshot="true"
|
||||||
|
android:description="@string/accessibility_service_description" />
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 根构建脚本:声明 Android Gradle Plugin 版本,供子模块应用
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.7.2' apply false
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
Vendored
+94
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user