feat: 4ma 用车计时助手 v1.0

- 无障碍服务监听7MA出行小程序,匹配关键词自动开始/结束计时
- 事件驱动截图+ML Kit离线中文OCR兜底,解决WebView无障碍树不稳定
- 常驻计时通知(15/18/30分钟提醒)与结束用车按钮
- 浅色主题主界面:权限状态、具体实现、安全/免责/法律声明
- 电池白名单+自启动引导+onUnbind自动重绑守护
- 无网络权限,完全离线运行
This commit is contained in:
kirl committed 2026-09-01 16:19:16 +08:00
commit 2cf3cbad56
34 files changed
+2158

No files matched your search

+32
View File
@@ -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'
}
+50
View File
@@ -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>
+7
View File
@@ -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>
+6
View File
@@ -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>
+395
View File
@@ -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

+16
View File
@@ -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>
+46
View File
@@ -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>
+11
View File
@@ -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" />