From 4cb877542a019c29fa466d8d50565620bff1e7d5 Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:02:26 +0800 Subject: [PATCH 01/10] fix: preserve pinyin dictionary resources in APK and secure keystore configuration --- app/build.gradle | 29 +++++++++++++++-------------- app/proguard-rules.pro | 11 +++++++++-- gradle.properties | 6 +++--- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5350ee7..8800674 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,10 +34,16 @@ android { signingConfigs { release { - storeFile file("touch_helper.keystore") - storePassword project.findProperty("KEYSTORE_PASSWORD") ?: System.getenv("KEYSTORE_PASSWORD") - keyAlias project.findProperty("KEY_ALIAS") ?: System.getenv("KEY_ALIAS") - keyPassword project.findProperty("KEY_PASSWORD") ?: System.getenv("KEY_PASSWORD") + def keystoreFile = file("touch_helper.keystore") + def storePass = project.findProperty("KEYSTORE_PASSWORD") ?: System.getenv("KEYSTORE_PASSWORD") + def keyAliasName = project.findProperty("KEY_ALIAS") ?: System.getenv("KEY_ALIAS") + def keyPass = project.findProperty("KEY_PASSWORD") ?: System.getenv("KEY_PASSWORD") + if (keystoreFile.exists() && storePass && keyAliasName && keyPass) { + storeFile keystoreFile + storePassword storePass + keyAlias keyAliasName + keyPassword keyPass + } } } @@ -47,7 +53,9 @@ android { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - signingConfig signingConfigs.release + if (signingConfigs.release.storeFile != null) { + signingConfig signingConfigs.release + } } } testOptions { @@ -57,22 +65,15 @@ android { } } packagingOptions { - jniLibs { - excludes += ['pinyindb/*'] - } resources { - excludes += ['pinyindb/*'] + excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/NOTICE'] } } // http://www.sollyu.com/android-apk-studio-generated-automatically-appends-a-version-number/ android.applicationVariants.all { variant -> variant.outputs.all { output -> - if(buildType.name == "release") { - output.outputFileName = new File("TouchHelper" + "-" + buildType.name + "-v" + defaultConfig.versionName + ".apk" ); - } else { - output.outputFileName = new File("TouchHelper" + "-" + buildType.name + "-v" + defaultConfig.versionName + ".apk" ); - } + output.outputFileName = "TouchHelper-${variant.buildType.name}-v${defaultConfig.versionName}.apk" } } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4aea034..5fb3994 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,14 +14,21 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile +-renamesourcefileattribute SourceFile -keep class * extends java.io.Serializable { *; } +# Keep project model classes serialized with GSON +-keepclassmembers class com.zfdang.touchhelper.PackagePositionDescription { ; } +-keepclassmembers class com.zfdang.touchhelper.PackageWidgetDescription { ; } + +# Keep pinyin4j resources and classes +-keep class net.sourceforge.pinyin4j.** { *; } + ##---------------Begin: proguard configuration for Gson ---------- # Gson uses generic type information stored in a class file when working with fields. Proguard # removes such information by default, so configure it to keep all of it. diff --git a/gradle.properties b/gradle.properties index 52bb027..ff97fc6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,6 @@ android.enableJetifier=false # Keystore credentials (DO NOT commit real values to version control) # Set these in local.properties or as environment variables -KEYSTORE_PASSWORD=touchhelper -KEY_ALIAS=touchhelper -KEY_PASSWORD=touchhelper \ No newline at end of file +# KEYSTORE_PASSWORD= +# KEY_ALIAS= +# KEY_PASSWORD= \ No newline at end of file From 00eb172e34178d75e4278e7437236a62f31254d6 Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:03:25 +0800 Subject: [PATCH 02/10] fix: enforce affirmative user consent on wakeup and update service config --- .../com/zfdang/touchhelper/TouchHelperServiceImpl.java | 7 +++++++ app/src/main/res/xml/accessibility_service_config.xml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 46fafa7..fde3965 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -1095,6 +1095,10 @@ private boolean click(int X, int Y, long start_time, long duration) { * keyguard, IME). */ private void resumeSkipAdAfterWakeup() { + if (!mSetting.isDisclosureAccepted()) { + remindDisclosureOnce(); + return; + } final String pkg = currentPackageName; if (pkg == null || !setPackages.contains(pkg)) { if (BuildConfig.DEBUG) { @@ -1147,6 +1151,9 @@ private boolean isScreenLockedOrOff() { * start the skip-ad process */ private void startSkipAdProcess() { + if (!mSetting.isDisclosureAccepted()) { + return; + } if (BuildConfig.DEBUG) { Log.d(TAG, "Start Skip-ad process"); } diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 164cf32..dcbb6a5 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,8 +1,8 @@ Date: Mon, 19 Oct 2026 17:05:29 +0800 Subject: [PATCH 03/10] fix: recycle AccessibilityNodeInfo instances and precompute bounds during overlay inspection --- .../touchhelper/TouchHelperServiceImpl.java | 113 +++++++++++++----- 1 file changed, 82 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index fde3965..7b650e1 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -1056,20 +1056,27 @@ private void findAllNode(List roots, List nodeList = new ArrayList<>(); StringBuilder dumpString = new StringBuilder(); - dumpChildNodes(root, nodeList, dumpString, ""); - return dumpString.toString(); + try { + dumpChildNodes(root, nodeList, dumpString, ""); + return dumpString.toString(); + } finally { + for (AccessibilityNodeInfo node : nodeList) { + recycleNode(node); + } + } } private void dumpChildNodes(AccessibilityNodeInfo root, List list, StringBuilder dumpString, String indent) { if(root == null) return; list.add(root); - dumpString.append(indent + Utilities.describeAccessibilityNode(root) + "\n"); + dumpString.append(indent).append(Utilities.describeAccessibilityNode(root)).append("\n"); for (int n = 0; n < root.getChildCount(); n++) { AccessibilityNodeInfo child = root.getChild(n); - dumpChildNodes(child, list, dumpString,indent + " "); + dumpChildNodes(child, list, dumpString, indent + " "); } } @@ -1239,6 +1246,31 @@ private void updatePackage() { this.setPackages = setPackages; } + private static class NodeOutlineData { + final Rect bounds; + final boolean clickable; + final String className; + final String idName; + final String description; + final String text; + final int area; + + NodeOutlineData(AccessibilityNodeInfo node) { + this.bounds = new Rect(); + node.getBoundsInScreen(this.bounds); + this.clickable = node.isClickable(); + CharSequence cls = node.getClassName(); + this.className = cls == null ? "" : cls.toString(); + CharSequence id = node.getViewIdResourceName(); + this.idName = id == null ? "" : id.toString(); + CharSequence desc = node.getContentDescription(); + this.description = desc == null ? "" : desc.toString(); + CharSequence txt = node.getText(); + this.text = txt == null ? "" : txt.toString(); + this.area = this.bounds.width() * this.bounds.height(); + } + } + // display activity customization dialog, and allow users to pick widget or positions @SuppressLint("ClickableViewAccessibility") private void showActivityCustomizationDialog() { @@ -1380,21 +1412,26 @@ public void onClick(View v) { layoutOverlayOutline.removeAllViews(); ArrayList roots = new ArrayList<>(); roots.add(root); - ArrayList nodeList = new ArrayList<>(); - findAllNode(roots, nodeList, ""); - Collections.sort(nodeList, new Comparator() { - @Override - public int compare(AccessibilityNodeInfo a, AccessibilityNodeInfo b) { - Rect rectA = new Rect(); - Rect rectB = new Rect(); - a.getBoundsInScreen(rectA); - b.getBoundsInScreen(rectB); - return rectB.width() * rectB.height() - rectA.width() * rectA.height(); + ArrayList allNodes = new ArrayList<>(); + List outlineDataList = new ArrayList<>(); + try { + findAllNode(roots, allNodes, ""); + for (AccessibilityNodeInfo e : allNodes) { + if (e != null) { + outlineDataList.add(new NodeOutlineData(e)); + } + } + } finally { + for (AccessibilityNodeInfo node : allNodes) { + recycleNode(node); } - }); - for (final AccessibilityNodeInfo e : nodeList) { - final Rect temRect = new Rect(); - e.getBoundsInScreen(temRect); + } + + // Sort by area descending without any Binder IPC + outlineDataList.sort((a, b) -> Integer.compare(b.area, a.area)); + + for (final NodeOutlineData data : outlineDataList) { + final Rect temRect = data.bounds; FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(temRect.width(), temRect.height()); params.leftMargin = temRect.left; params.topMargin = temRect.top; @@ -1411,19 +1448,29 @@ public void onClick(View v) { @Override public void onFocusChange(View v, boolean hasFocus) { if (hasFocus) { - widgetDescription.position = temRect; - widgetDescription.clickable = e.isClickable(); - widgetDescription.className = e.getClassName().toString(); - CharSequence cId = e.getViewIdResourceName(); - widgetDescription.idName = cId == null ? "" : cId.toString(); - CharSequence cDesc = e.getContentDescription(); - widgetDescription.description = cDesc == null ? "" : cDesc.toString(); - CharSequence cText = e.getText(); - widgetDescription.text = cText == null ? "" : cText.toString(); + widgetDescription.position = new Rect(temRect); + widgetDescription.clickable = data.clickable; + widgetDescription.className = data.className; + widgetDescription.idName = data.idName; + widgetDescription.description = data.description; + widgetDescription.text = data.text; btAddWidget.setEnabled(true); tvPackageName.setText(widgetDescription.packageName); tvActivityName.setText(widgetDescription.activityName); - tvWidgetInfo.setText("click:" + (e.isClickable() ? "true" : "false") + " " + "bonus:" + temRect.toShortString() + " " + "id:" + (cId == null ? "null" : cId.toString().substring(cId.toString().indexOf("id/") + 3)) + " " + "desc:" + (cDesc == null ? "null" : cDesc.toString()) + " " + "text:" + (cText == null ? "null" : cText.toString())); + + String idDisplay; + int idIdx = data.idName.indexOf("id/"); + if (idIdx >= 0) { + idDisplay = data.idName.substring(idIdx + 3); + } else { + idDisplay = data.idName.isEmpty() ? "null" : data.idName; + } + + tvWidgetInfo.setText("click:" + (data.clickable ? "true" : "false") + + " bonus:" + temRect.toShortString() + + " id:" + idDisplay + + " desc:" + (data.description.isEmpty() ? "null" : data.description) + + " text:" + (data.text.isEmpty() ? "null" : data.text)); v.setBackgroundResource(R.drawable.node_focus); } else { v.setBackgroundResource(R.drawable.node); @@ -1506,11 +1553,15 @@ public void onClick(View v) { if (root == null) return; String result = dumpRootNode(root); - Log.d(TAG, result); + if (BuildConfig.DEBUG) { + Log.d(TAG, result); + } ClipboardManager clipboard = (ClipboardManager) service.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("ACTIVITY", result); - clipboard.setPrimaryClip(clip); + if (clipboard != null) { + ClipData clip = ClipData.newPlainText("ACTIVITY", result); + clipboard.setPrimaryClip(clip); + } ShowToastInIntentService("窗口控件已复制到剪贴板!"); } From 95ac160844efb750e3785bd5e1eb1410c4fd437c Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:07:31 +0800 Subject: [PATCH 04/10] fix: add package scheme and export flag to receivers and include custom-ruled apps --- .../touchhelper/TouchHelperServiceImpl.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 7b650e1..1a43ac0 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -35,6 +35,8 @@ import android.widget.TextView; import android.widget.Toast; +import androidx.core.content.ContextCompat; + import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; @@ -270,12 +272,13 @@ private void InstallReceiverAndHandler() { // without a lock screen. Both are protected system broadcasts. IntentFilter wakeup = new IntentFilter(Intent.ACTION_USER_PRESENT); wakeup.addAction(Intent.ACTION_SCREEN_ON); - service.registerReceiver(userPresentReceiver, wakeup); + ContextCompat.registerReceiver(service, userPresentReceiver, wakeup, ContextCompat.RECEIVER_EXPORTED); packageChangeReceiver = new PackageChangeReceiver(); IntentFilter actions = new IntentFilter(); actions.addAction(Intent.ACTION_PACKAGE_ADDED); actions.addAction(Intent.ACTION_PACKAGE_REMOVED); - service.registerReceiver(packageChangeReceiver, actions); + actions.addDataScheme("package"); + ContextCompat.registerReceiver(service, packageChangeReceiver, actions, ContextCompat.RECEIVER_EXPORTED); // install handler to handle broadcast messages receiverHandler = new Handler(Looper.getMainLooper(), msg -> { @@ -394,7 +397,7 @@ public void onAccessibilityEvent(AccessibilityEvent event) { wakeupPending = false; // stop current skip ad process if it exists stopSkipAdProcess(); - if(setPackages.contains(pkgName)) { + if(isTargetPackage(pkgName)) { // if the package is in our list, start skip ads process // this is the only place to start skip ad process startSkipAdProcess(); @@ -411,7 +414,7 @@ public void onAccessibilityEvent(AccessibilityEvent event) { if (wakeupPending && !isScreenLockedOrOff()) { // the covered app is back and the device is unlocked: warm start wakeupPending = false; - if (setPackages.contains(pkgName)) { + if (isTargetPackage(pkgName)) { if (BuildConfig.DEBUG) { Log.d(TAG, "warm start of " + pkgName + " after the lock screen, start to skip ads"); } @@ -1094,6 +1097,12 @@ private boolean click(int X, int Y, long start_time, long duration) { // apps that show a warm-start ad after the screen was unlocked do so within about a second private static final long WAKEUP_RESCAN_DELAY_MS = 1000; + private boolean isTargetPackage(String pkg) { + return pkg != null && (setPackages.contains(pkg) + || mapPackageWidgets.containsKey(pkg) + || mapPackagePositions.containsKey(pkg)); + } + /** * The screen was turned on or unlocked while an app was in the foreground. Many apps show a * splash ad on such a warm start, so restart the skip-ad process for that app and scan its @@ -1107,7 +1116,7 @@ private void resumeSkipAdAfterWakeup() { return; } final String pkg = currentPackageName; - if (pkg == null || !setPackages.contains(pkg)) { + if (pkg == null || !isTargetPackage(pkg)) { if (BuildConfig.DEBUG) { Log.d(TAG, "wakeup while in " + pkg + ", not a target package"); } From 7655dd4715058f2202062ae6fa16f6661882c681 Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:09:08 +0800 Subject: [PATCH 05/10] refactor: enhance Settings concurrency, add defensive copies and protect against corrupted JSON --- .../java/com/zfdang/touchhelper/Settings.java | 203 ++++++++++++------ 1 file changed, 134 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/Settings.java b/app/src/main/java/com/zfdang/touchhelper/Settings.java index 730efa6..097d836 100644 --- a/app/src/main/java/com/zfdang/touchhelper/Settings.java +++ b/app/src/main/java/com/zfdang/touchhelper/Settings.java @@ -1,6 +1,7 @@ package com.zfdang.touchhelper; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; @@ -23,25 +24,36 @@ import java.util.TreeMap; public class Settings { - private final String TAG = "Settings"; - private final String Preference_Name = "TouchHelper_Config"; + private static final String TAG = "Settings"; + private static final String Preference_Name = "TouchHelper_Config"; private SharedPreferences mPreference; - private SharedPreferences.Editor mEditor; private Gson mJson; - // Singleton - private static Settings ourInstance = new Settings(); + // Singleton with thread-safe lazy initialization + private static volatile Settings ourInstance; public static Settings getInstance() { + if (ourInstance == null) { + synchronized (Settings.class) { + if (ourInstance == null) { + ourInstance = new Settings(); + } + } + } return ourInstance; } + private Settings() { initSettings(); } private void initSettings() { - mPreference = TouchHelperApp.getAppContext().getSharedPreferences(Preference_Name, Activity.MODE_PRIVATE); - mEditor = mPreference.edit(); + Context context = TouchHelperApp.getAppContext(); + if (context == null) { + Log.e(TAG, "TouchHelperApp context is not yet available during Settings initialization."); + return; + } + mPreference = context.getSharedPreferences(Preference_Name, Activity.MODE_PRIVATE); mJson = new Gson(); // init all settings from SharedPreferences @@ -52,14 +64,11 @@ private void initSettings() { iSkipAdDuration = mPreference.getInt(SKIP_AD_DURATION, 4); // init whitelist of packages - // https://stackoverflow.com/questions/10720028/android-sharedpreferences-not-saving - // Note that you must not modify the set instance returned by this call. The consistency of the stored data is not guaranteed if you do, nor is your ability to modify the instance at all. Set storedWhitelist = mPreference.getStringSet(WHITELIST_PACKAGE, null); if (storedWhitelist != null) { setWhiteListPackages = new HashSet<>(storedWhitelist); } else { - // first run only: default the whitelist to all system packages. This is a - // PackageManager round-trip, so it is skipped once a value has been persisted. + // first run only: default the whitelist to all system packages. setWhiteListPackages = querySystemLauncherPackages(); } @@ -74,34 +83,58 @@ private void initSettings() { Log.d(TAG, Utilities.getTraceStackInString(e)); } } - listKeyWords = stored == null ? Collections.singletonList("跳过") : SkipAdRules.parseKeywords(String.join(" ", stored)); + if (stored != null && !stored.isEmpty()) { + List cleaned = new ArrayList<>(); + for (String s : stored) { + if (s != null && !s.trim().isEmpty() && !cleaned.contains(s.trim())) { + cleaned.add(s.trim()); + } + } + listKeyWords = Collections.unmodifiableList(cleaned); + } else { + listKeyWords = Collections.singletonList("跳过"); + } - // load activity widgets + // load activity widgets with JSON corruption protection json = mPreference.getString(PACKAGE_WIDGETS, null); + mapPackageWidgets = new TreeMap<>(); if (json != null) { - Type type = new TypeToken>>() {}.getType(); - mapPackageWidgets = mJson.fromJson(json, type); - } else { - mapPackageWidgets = new TreeMap<>(); + try { + Type type = new TypeToken>>() {}.getType(); + TreeMap> parsed = mJson.fromJson(json, type); + if (parsed != null) { + mapPackageWidgets = parsed; + } + } catch (Exception e) { + Log.e(TAG, "Corrupted PACKAGE_WIDGETS in preferences: " + e.getMessage()); + } } - // load activity positions + // load activity positions with JSON corruption protection json = mPreference.getString(PACKAGE_POSITIONS, null); + mapPackagePositions = new TreeMap<>(); if (json != null) { - Type type = new TypeToken>() {}.getType(); - mapPackagePositions = mJson.fromJson(json, type); - } else { - mapPackagePositions = new TreeMap<>(); + try { + Type type = new TypeToken>() {}.getType(); + TreeMap parsed = mJson.fromJson(json, type); + if (parsed != null) { + mapPackagePositions = parsed; + } + } catch (Exception e) { + Log.e(TAG, "Corrupted PACKAGE_POSITIONS in preferences: " + e.getMessage()); + } } - } private static Set querySystemLauncherPackages() { Set pkgSystems = new HashSet<>(); - PackageManager packageManager = TouchHelperApp.getAppContext().getPackageManager(); + Context context = TouchHelperApp.getAppContext(); + if (context == null) return pkgSystems; + PackageManager packageManager = context.getPackageManager(); + if (packageManager == null) return pkgSystems; Intent intent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER); - List ResolveInfoList = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL); - for (ResolveInfo e : ResolveInfoList) { + List resolveInfoList = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL); + for (ResolveInfo e : resolveInfoList) { if (e.activityInfo != null && e.activityInfo.applicationInfo != null && (e.activityInfo.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) == ApplicationInfo.FLAG_SYSTEM) { pkgSystems.add(e.activityInfo.packageName); @@ -111,9 +144,6 @@ private static Set querySystemLauncherPackages() { } // has the user confirmed the in-app disclosure about what the accessibility service reads? - // Google Play requires this consent before the service may process screen content, so the - // service stays idle until it is recorded (also for users who enabled the service before - // the disclosure existed). private static final String ACCESSIBILITY_DISCLOSURE_ACCEPTED = "ACCESSIBILITY_DISCLOSURE_ACCEPTED"; private volatile boolean bDisclosureAccepted; public boolean isDisclosureAccepted() { @@ -121,40 +151,35 @@ public boolean isDisclosureAccepted() { } public void setDisclosureAccepted(boolean accepted) { bDisclosureAccepted = accepted; - mEditor.putBoolean(ACCESSIBILITY_DISCLOSURE_ACCEPTED, accepted); - mEditor.apply(); + mPreference.edit().putBoolean(ACCESSIBILITY_DISCLOSURE_ACCEPTED, accepted).apply(); } // notification on skip ads? private static final String SKIP_AD_NOTIFICATION = "SKIP_AD_NOTIFICATION"; - private boolean bSkipAdNotification; + private volatile boolean bSkipAdNotification; public boolean isSkipAdNotification() { return bSkipAdNotification; } public void setSkipAdNotification(boolean bSkipAdNotification) { if (this.bSkipAdNotification != bSkipAdNotification) { this.bSkipAdNotification = bSkipAdNotification; - mEditor.putBoolean(SKIP_AD_NOTIFICATION, this.bSkipAdNotification); - mEditor.apply(); + mPreference.edit().putBoolean(SKIP_AD_NOTIFICATION, this.bSkipAdNotification).apply(); } } - // duration of skip ad process private static final String SKIP_AD_DURATION = "SKIP_AD_DURATION"; - private int iSkipAdDuration; + private volatile int iSkipAdDuration; public int getSkipAdDuration() { return iSkipAdDuration; } public void setSkipAdDuration(int iSkipAdDuration) { if (this.iSkipAdDuration != iSkipAdDuration) { this.iSkipAdDuration = iSkipAdDuration; - mEditor.putInt(SKIP_AD_DURATION, this.iSkipAdDuration); - mEditor.apply(); + mPreference.edit().putInt(SKIP_AD_DURATION, this.iSkipAdDuration).apply(); } } - // whitelist of packages private static final String WHITELIST_PACKAGE = "WHITELIST_PACKAGE"; private volatile Set setWhiteListPackages; @@ -163,50 +188,72 @@ public synchronized Set getWhitelistPackages() { } public synchronized void setWhitelistPackages(Set pkgs) { setWhiteListPackages = new HashSet<>(pkgs); - // https://stackoverflow.com/questions/10720028/android-sharedpreferences-not-saving - mEditor.putStringSet(WHITELIST_PACKAGE, new HashSet(setWhiteListPackages)); - mEditor.apply(); + mPreference.edit().putStringSet(WHITELIST_PACKAGE, new HashSet<>(setWhiteListPackages)).apply(); } // list of key words private static final String KEY_WORDS_LIST = "KEY_WORDS_LIST"; - // Never mutated in place: the accessibility service iterates this list on its worker - // thread, so every change installs a fresh immutable list instead. private volatile List listKeyWords; public List getKeyWordList() { return listKeyWords; } public String getKeyWordsAsString() { return String.join(" ", listKeyWords); } - public void setKeyWordList(String text) { + public synchronized void setKeyWordList(String text) { List keys = SkipAdRules.parseKeywords(text); listKeyWords = Collections.unmodifiableList(keys); String json = mJson.toJson(keys); - mEditor.putString(KEY_WORDS_LIST, json); - mEditor.apply(); + mPreference.edit().putString(KEY_WORDS_LIST, json).apply(); } // map of key activity widgets private static final String PACKAGE_WIDGETS = "PACKAGE_WIDGETS"; private volatile Map> mapPackageWidgets; - public Map> getPackageWidgets() { return mapPackageWidgets; } - public void setPackageWidgets(Map> map) { - mapPackageWidgets = map; - String json = mJson.toJson(mapPackageWidgets); -// Log.d(TAG, json); - mEditor.putString(PACKAGE_WIDGETS, json); - mEditor.apply(); + + public synchronized Map> getPackageWidgets() { + Map> copy = new TreeMap<>(); + if (mapPackageWidgets != null) { + for (Map.Entry> entry : mapPackageWidgets.entrySet()) { + if (entry.getValue() != null) { + Set setCopy = new HashSet<>(); + for (PackageWidgetDescription item : entry.getValue()) { + setCopy.add(new PackageWidgetDescription(item)); + } + copy.put(entry.getKey(), setCopy); + } + } + } + return copy; } - public String getPackageWidgetsInString() { + + public synchronized void setPackageWidgets(Map> map) { + Map> copy = new TreeMap<>(); + if (map != null) { + for (Map.Entry> entry : map.entrySet()) { + if (entry.getValue() != null) { + Set setCopy = new HashSet<>(); + for (PackageWidgetDescription item : entry.getValue()) { + setCopy.add(new PackageWidgetDescription(item)); + } + copy.put(entry.getKey(), setCopy); + } + } + } + mapPackageWidgets = copy; String json = mJson.toJson(mapPackageWidgets); - return json; + mPreference.edit().putString(PACKAGE_WIDGETS, json).apply(); + } + + public synchronized String getPackageWidgetsInString() { + return mJson.toJson(mapPackageWidgets); } - public boolean setPackageWidgetsInString(String value) { + + public synchronized boolean setPackageWidgetsInString(String value) { if (value != null) { try { - Type type = new TypeToken>>() { - }.getType(); - mapPackageWidgets = mJson.fromJson(value, type); - mEditor.putString(PACKAGE_WIDGETS, value); - mEditor.apply(); - return true; + Type type = new TypeToken>>() {}.getType(); + TreeMap> parsed = mJson.fromJson(value, type); + if (parsed != null) { + setPackageWidgets(parsed); + return true; + } } catch (JsonSyntaxException e) { Log.d(TAG, Utilities.getTraceStackInString(e)); return false; @@ -218,13 +265,31 @@ public boolean setPackageWidgetsInString(String value) { // map of key package positions private static final String PACKAGE_POSITIONS = "PACKAGE_POSITIONS"; private volatile Map mapPackagePositions; - public Map getPackagePositions() { return mapPackagePositions; } - public void setPackagePositions(Map map) { - mapPackagePositions = map; + + public synchronized Map getPackagePositions() { + Map copy = new TreeMap<>(); + if (mapPackagePositions != null) { + for (Map.Entry entry : mapPackagePositions.entrySet()) { + if (entry.getValue() != null) { + copy.put(entry.getKey(), new PackagePositionDescription(entry.getValue())); + } + } + } + return copy; + } + + public synchronized void setPackagePositions(Map map) { + Map copy = new TreeMap<>(); + if (map != null) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() != null) { + copy.put(entry.getKey(), new PackagePositionDescription(entry.getValue())); + } + } + } + mapPackagePositions = copy; String json = mJson.toJson(mapPackagePositions); -// Log.d(TAG, json); - mEditor.putString(PACKAGE_POSITIONS, json); - mEditor.apply(); + mPreference.edit().putString(PACKAGE_POSITIONS, json).apply(); } } From 286b5821ce0bcdaba0e49dcd659bc4f0dec73a26 Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:14:00 +0800 Subject: [PATCH 06/10] refactor: make models and rule matching null-safe, robust and case-insensitive --- .../PackagePositionDescription.java | 45 +++++++++++++--- .../touchhelper/PackageWidgetDescription.java | 54 +++++++++++-------- .../com/zfdang/touchhelper/SkipAdRules.java | 53 +++++++++++++++--- .../touchhelper/TouchHelperServiceImpl.java | 21 +++++--- 4 files changed, 133 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/PackagePositionDescription.java b/app/src/main/java/com/zfdang/touchhelper/PackagePositionDescription.java index 5b4afb1..8e0ade9 100644 --- a/app/src/main/java/com/zfdang/touchhelper/PackagePositionDescription.java +++ b/app/src/main/java/com/zfdang/touchhelper/PackagePositionDescription.java @@ -1,6 +1,7 @@ package com.zfdang.touchhelper; import java.io.Serializable; +import java.util.Objects; public class PackagePositionDescription implements Serializable { public String packageName; @@ -16,16 +17,48 @@ public PackagePositionDescription() { } public PackagePositionDescription(String packageName, String activityName, int x, int y) { - this.packageName = packageName; - this.activityName = activityName; + this.packageName = packageName != null ? packageName : ""; + this.activityName = activityName != null ? activityName : ""; this.x = x; this.y = y; } public PackagePositionDescription(PackagePositionDescription positionDescription) { - this.packageName = positionDescription.packageName; - this.activityName = positionDescription.activityName; - this.x = positionDescription.x; - this.y = positionDescription.y; + if (positionDescription != null) { + this.packageName = positionDescription.packageName != null ? positionDescription.packageName : ""; + this.activityName = positionDescription.activityName != null ? positionDescription.activityName : ""; + this.x = positionDescription.x; + this.y = positionDescription.y; + } else { + this.packageName = ""; + this.activityName = ""; + this.x = 0; + this.y = 0; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PackagePositionDescription that = (PackagePositionDescription) o; + return x == that.x && y == that.y + && Objects.equals(packageName, that.packageName) + && Objects.equals(activityName, that.activityName); + } + + @Override + public int hashCode() { + return Objects.hash(packageName, activityName, x, y); + } + + @Override + public String toString() { + return "PackagePositionDescription{" + + "packageName='" + packageName + '\'' + + ", activityName='" + activityName + '\'' + + ", x=" + x + + ", y=" + y + + '}'; } } diff --git a/app/src/main/java/com/zfdang/touchhelper/PackageWidgetDescription.java b/app/src/main/java/com/zfdang/touchhelper/PackageWidgetDescription.java index dff3889..2873bb5 100644 --- a/app/src/main/java/com/zfdang/touchhelper/PackageWidgetDescription.java +++ b/app/src/main/java/com/zfdang/touchhelper/PackageWidgetDescription.java @@ -23,35 +23,47 @@ public PackageWidgetDescription() { } public PackageWidgetDescription(String packageName, String activityName, String className, String idName, String description, String text, Rect position, boolean clickable, boolean onlyClick) { - this.packageName = packageName; - this.activityName = activityName; - this.className = className; - this.idName = idName; - this.description = description; - this.text = text; - this.position = position; + this.packageName = packageName != null ? packageName : ""; + this.activityName = activityName != null ? activityName : ""; + this.className = className != null ? className : ""; + this.idName = idName != null ? idName : ""; + this.description = description != null ? description : ""; + this.text = text != null ? text : ""; + this.position = position != null ? new Rect(position) : null; this.clickable = clickable; this.onlyClick = onlyClick; } public PackageWidgetDescription(PackageWidgetDescription widgetDescription) { - this.packageName = widgetDescription.packageName; - this.activityName = widgetDescription.activityName; - this.className = widgetDescription.className; - this.idName = widgetDescription.idName; - this.description = widgetDescription.description; - this.text = widgetDescription.text; - if (widgetDescription.position != null) { - this.position = new Rect(); - this.position.left = widgetDescription.position.left; - this.position.top = widgetDescription.position.top; - this.position.right = widgetDescription.position.right; - this.position.bottom = widgetDescription.position.bottom; + if (widgetDescription != null) { + this.packageName = widgetDescription.packageName != null ? widgetDescription.packageName : ""; + this.activityName = widgetDescription.activityName != null ? widgetDescription.activityName : ""; + this.className = widgetDescription.className != null ? widgetDescription.className : ""; + this.idName = widgetDescription.idName != null ? widgetDescription.idName : ""; + this.description = widgetDescription.description != null ? widgetDescription.description : ""; + this.text = widgetDescription.text != null ? widgetDescription.text : ""; + if (widgetDescription.position != null) { + this.position = new Rect(); + this.position.left = widgetDescription.position.left; + this.position.top = widgetDescription.position.top; + this.position.right = widgetDescription.position.right; + this.position.bottom = widgetDescription.position.bottom; + } else { + this.position = null; + } + this.clickable = widgetDescription.clickable; + this.onlyClick = widgetDescription.onlyClick; } else { + this.packageName = ""; + this.activityName = ""; + this.className = ""; + this.idName = ""; + this.description = ""; + this.text = ""; this.position = null; + this.clickable = false; + this.onlyClick = false; } - this.clickable = widgetDescription.clickable; - this.onlyClick = widgetDescription.onlyClick; } @Override diff --git a/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java b/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java index 2dbe95d..fbd69a5 100644 --- a/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java +++ b/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java @@ -22,17 +22,23 @@ private SkipAdRules() { /** * Splits the user-entered keyword string on whitespace and drops blank entries. + * Supports Unicode whitespace (e.g. full-width spaces). * A blank keyword would otherwise match every short label on screen. */ public static List parseKeywords(String text) { if (text == null) { return Collections.emptyList(); } - String[] parts = text.trim().split("\\s+"); + String stripped = text.replaceAll("^[\\s\\p{Z}]+|[\\s\\p{Z}]+$", ""); + if (stripped.isEmpty()) { + return Collections.emptyList(); + } + String[] parts = stripped.split("(?U)\\s+"); ArrayList result = new ArrayList<>(parts.length); for (String part : parts) { - if (!part.isEmpty() && !result.contains(part)) { - result.add(part); + String clean = stripInvisibleChars(part).trim(); + if (!clean.isEmpty() && !result.contains(clean)) { + result.add(clean); } } return result; @@ -69,10 +75,42 @@ public static String findKeyword(String text, String description, List k return null; } - private static boolean matchesKeyword(String label, String keyword, String excludedText) { - return label.length() <= keyword.length() + KEYWORD_MAX_EXTRA_LENGTH - && label.contains(keyword) - && !label.equals(excludedText); + static boolean matchesKeyword(String label, String keyword, String excludedText) { + if (label == null || keyword == null) { + return false; + } + String cleanLabel = stripInvisibleChars(label); + String cleanKeyword = stripInvisibleChars(keyword); + if (cleanLabel.length() > cleanKeyword.length() + KEYWORD_MAX_EXTRA_LENGTH) { + return false; + } + if (!containsIgnoreCase(cleanLabel, cleanKeyword)) { + return false; + } + return excludedText == null || !cleanLabel.equalsIgnoreCase(excludedText); + } + + static boolean containsIgnoreCase(String src, String what) { + if (src == null || what == null) { + return false; + } + final int length = what.length(); + if (length == 0) { + return true; + } + for (int i = 0; i <= src.length() - length; i++) { + if (src.regionMatches(true, i, what, 0, length)) { + return true; + } + } + return false; + } + + static String stripInvisibleChars(String text) { + if (text == null) { + return ""; + } + return text.replaceAll("[\u200B-\u200D\uFEFF]", ""); } /** @@ -110,6 +148,7 @@ public static PackageWidgetDescription findWidget(int left, int top, int right, private static boolean boundsEqual(PackageWidgetDescription rule, int left, int top, int right, int bottom) { return rule.position != null + && (rule.position.left < rule.position.right && rule.position.top < rule.position.bottom) && rule.position.left == left && rule.position.top == top && rule.position.right == right diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 1a43ac0..7b609d1 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -89,6 +89,7 @@ public class TouchHelperServiceImpl { // the session they started under and only ever write to that one, so a click that completes // after the user re-entered the app cannot leak into the new process. See clickNode(). private volatile ClickSession session = new ClickSession(); + private volatile Future positionClickFuture; private static final class ClickSession { /** widgets clicked in this process, keyed by clickKey() */ @@ -454,15 +455,17 @@ public void onAccessibilityEvent(AccessibilityEvent event) { if (packagePositionDescription != null) { ShowToastInIntentService("正在根据位置跳过广告..."); - // try to click the position in the activity for multiple times - final Future[] futures = {null}; - futures[0] = taskExecutorService.scheduleWithFixedDelay(new Runnable() { + if (positionClickFuture != null) { + positionClickFuture.cancel(true); + } + positionClickFuture = taskExecutorService.scheduleWithFixedDelay(new Runnable() { int num = 0; @Override public void run() { if (num < PACKAGE_POSITION_CLICK_RETRY && skipAdRunning) { - if(currentActivityName.equals(packagePositionDescription.activityName)) { - // current activity is null, or current activity is the target activity + if (packagePositionDescription.activityName == null + || packagePositionDescription.activityName.isEmpty() + || packagePositionDescription.activityName.equals(currentActivityName)) { if (BuildConfig.DEBUG) { Log.d(TAG, "Find skip-ad by position, simulate click now! "); } @@ -470,7 +473,9 @@ public void run() { } num ++; } else { - futures[0].cancel(true); + if (positionClickFuture != null) { + positionClickFuture.cancel(true); + } } } }, PACKAGE_POSITION_CLICK_FIRST_DELAY, PACKAGE_POSITION_CLICK_RETRY_INTERVAL, TimeUnit.MILLISECONDS); @@ -1207,6 +1212,10 @@ private void stopSkipAdProcessInner() { skipAdByKeyword = false; setTargetedWidgets = null; clearPendingScans(); + if (positionClickFuture != null) { + positionClickFuture.cancel(true); + positionClickFuture = null; + } } /** From e3eb293faa338073732087a5dcf0e8dff1bc0d9e Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:16:05 +0800 Subject: [PATCH 07/10] fix: safeguard clipboard pasting, overlay removal and dialog lifecycle --- .../touchhelper/TouchHelperServiceImpl.java | 42 ++++++----- .../ManagePackageWidgetsDialogFragment.java | 69 +++++++++++-------- 2 files changed, 63 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 7b609d1..4e0bdca 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -239,6 +239,7 @@ public void onServiceConnected() { public void onInterrupt(){ dismissActivityCustomizationDialog(); + stopSkipAdProcess(); } /** @@ -1581,7 +1582,9 @@ public void onClick(View v) { clipboard.setPrimaryClip(clip); } - ShowToastInIntentService("窗口控件已复制到剪贴板!"); + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + ShowToastInIntentService("窗口控件已复制到剪贴板!"); + } } }); btQuit.setOnClickListener(v -> dismissActivityCustomizationDialog()); @@ -1598,26 +1601,27 @@ public void dismissActivityCustomizationDialog() { if (!isShow) { return; } - try { - final WindowManager windowManager = (WindowManager) service.getSystemService(AccessibilityService.WINDOW_SERVICE); - if (windowManager != null) { - if (overlayViewTarget != null) { - windowManager.removeViewImmediate(overlayViewTarget); - } - if (overlayViewCustomization != null) { - windowManager.removeViewImmediate(overlayViewCustomization); - } - if (overlayImageTarget != null) { - windowManager.removeViewImmediate(overlayImageTarget); + final WindowManager windowManager = (WindowManager) service.getSystemService(AccessibilityService.WINDOW_SERVICE); + if (windowManager != null) { + removeOverlayView(windowManager, overlayViewTarget); + removeOverlayView(windowManager, overlayViewCustomization); + removeOverlayView(windowManager, overlayImageTarget); + } + overlayViewTarget = null; + overlayViewCustomization = null; + overlayImageTarget = null; + isShow = false; + } + + private void removeOverlayView(WindowManager wm, View view) { + if (view != null) { + try { + if (view.isAttachedToWindow()) { + wm.removeViewImmediate(view); } + } catch (Throwable e) { + Log.w(TAG, "Failed to remove overlay view: " + e.getMessage()); } - } catch (Throwable e) { - Log.e(TAG, Utilities.getTraceStackInString(e)); - } finally { - overlayViewTarget = null; - overlayViewCustomization = null; - overlayImageTarget = null; - isShow = false; } } diff --git a/app/src/main/java/com/zfdang/touchhelper/ui/settings/ManagePackageWidgetsDialogFragment.java b/app/src/main/java/com/zfdang/touchhelper/ui/settings/ManagePackageWidgetsDialogFragment.java index 10c8abc..801f14a 100644 --- a/app/src/main/java/com/zfdang/touchhelper/ui/settings/ManagePackageWidgetsDialogFragment.java +++ b/app/src/main/java/com/zfdang/touchhelper/ui/settings/ManagePackageWidgetsDialogFragment.java @@ -32,18 +32,21 @@ public class ManagePackageWidgetsDialogFragment extends DialogFragment { @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - - getDialog().setCanceledOnTouchOutside(false); + if (getDialog() != null) { + getDialog().setCanceledOnTouchOutside(false); + } View view = inflater.inflate(R.layout.layout_manage_package_widgets, container, false); editRules = view.findViewById(R.id.editText_rules); setting = Settings.getInstance(); originalRules = setting.getPackageWidgetsInString(); - editRules.setText(originalRules); + if (savedInstanceState == null) { + editRules.setText(originalRules); + } Button btReset = view.findViewById(R.id.button_reset); - if(btReset != null) { + if (btReset != null) { btReset.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -53,53 +56,61 @@ public void onClick(View view) { } Button btCopy = view.findViewById(R.id.button_copy); - if(btCopy != null) { + if (btCopy != null) { btCopy.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - // Gets a handle to the clipboard service. - ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("Rules: Widget in Packages", editRules.getText().toString()); - clipboard.setPrimaryClip(clip); - - Utilities.toast("规则已复制到剪贴板!"); + Context context = getContext(); + if (context == null) return; + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null) { + ClipData clip = ClipData.newPlainText("Rules: Widget in Packages", editRules.getText().toString()); + clipboard.setPrimaryClip(clip); + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + Utilities.toast("规则已复制到剪贴板!"); + } + } } }); } - Button btPaste = view.findViewById(R.id.button_paste); - if(btPaste != null) { + if (btPaste != null) { btPaste.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if(clipData != null && clipData.getItemCount() > 0) { - String pasteData = clipData.getItemAt(0).getText().toString(); - editRules.setText(pasteData); - Utilities.toast("已从剪贴板获取规则!"); - } else { - Utilities.toast("未从剪贴板发现规则!"); + Context context = getContext(); + if (context == null) return; + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null && clipboard.hasPrimaryClip()) { + ClipData clipData = clipboard.getPrimaryClip(); + if (clipData != null && clipData.getItemCount() > 0) { + CharSequence text = clipData.getItemAt(0).coerceToText(context); + if (text != null && text.length() > 0) { + editRules.setText(text.toString()); + Utilities.toast("已从剪贴板获取规则!"); + return; + } + } } + Utilities.toast("未从剪贴板发现规则!"); } }); } - Button btCancel = view.findViewById(R.id.button_widgets_cancel); - if(btCancel != null) { + if (btCancel != null) { btCancel.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Utilities.toast("修改已取消"); - getDialog().dismiss(); + dismiss(); } }); } Button btRules = view.findViewById(R.id.button_widgets_rules); - if(btRules != null) { + if (btRules != null) { btRules.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -116,14 +127,15 @@ public void onClick(View view) { } Button btConfirm = view.findViewById(R.id.button_widgets_confirm); - if(btConfirm != null) { + if (btConfirm != null) { btConfirm.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { boolean result = setting.setPackageWidgetsInString(editRules.getText().toString()); - if(result) { + if (result) { Utilities.toast("规则已保存!"); - getDialog().dismiss(); + com.zfdang.touchhelper.TouchHelperService.dispatchAction(com.zfdang.touchhelper.TouchHelperService.ACTION_REFRESH_CUSTOMIZED_ACTIVITY); + dismiss(); } else { Utilities.toast("规则有误,请修改后再次保存!"); } @@ -131,7 +143,6 @@ public void onClick(View view) { }); } - return view; } } From 141cd083ac1dc4e610117688a92a73f19999b285 Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 17:20:06 +0800 Subject: [PATCH 08/10] refactor: prevent UI memory leaks, clean up layouts and enforce MVVM encapsulation --- .../touchhelper/ui/home/HomeFragment.java | 15 +--- .../touchhelper/ui/home/HomeViewModel.java | 35 ++++---- .../ui/settings/SettingsFragment.java | 84 ++++++------------- app/src/main/res/layout/fragment_home.xml | 6 +- app/src/main/res/layout/fragment_settings.xml | 22 ----- .../main/res/navigation/mobile_navigation.xml | 3 +- 6 files changed, 53 insertions(+), 112 deletions(-) delete mode 100644 app/src/main/res/layout/fragment_settings.xml diff --git a/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java b/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java index ef30e86..6ce12bf 100644 --- a/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java +++ b/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java @@ -8,7 +8,6 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.os.PowerManager; import android.provider.Settings; import android.view.LayoutInflater; import android.view.View; @@ -21,7 +20,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; -import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; @@ -154,14 +152,9 @@ private void openAccessibilitySettings() { } public void checkServiceStatus(){ - // detect the accessibility permission - MutableLiveData accessibility = homeViewModel.getAccessibilityPermission(); - accessibility.setValue(TouchHelperService.isServiceRunning()); - - // detect power optimization - PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); - boolean hasIgnored = pm.isIgnoringBatteryOptimizations(getContext().getPackageName()); - MutableLiveData power = homeViewModel.getPowerOptimization(); - power.setValue(hasIgnored); + Context context = getContext(); + if (context != null) { + homeViewModel.refreshServiceStatus(context.getApplicationContext()); + } } } \ No newline at end of file diff --git a/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeViewModel.java b/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeViewModel.java index 7d8e5f0..e4150d8 100644 --- a/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeViewModel.java +++ b/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeViewModel.java @@ -1,37 +1,38 @@ package com.zfdang.touchhelper.ui.home; +import android.content.Context; +import android.os.PowerManager; + import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; -public class HomeViewModel extends ViewModel { +import com.zfdang.touchhelper.TouchHelperService; - private MutableLiveData mText; +public class HomeViewModel extends ViewModel { - private MutableLiveData mAppPermission; - private MutableLiveData mAccessibilityPermission; - private MutableLiveData mPowerOptimization; + private final MutableLiveData mAccessibilityPermission; + private final MutableLiveData mPowerOptimization; public HomeViewModel() { - mText = new MutableLiveData<>(); - mAppPermission = new MutableLiveData<>(); mAccessibilityPermission = new MutableLiveData<>(); mPowerOptimization = new MutableLiveData<>(); } - public LiveData getText() { - return mText; - } - - public MutableLiveData getAppPermission() { - return mAppPermission; - } - - public MutableLiveData getAccessibilityPermission() { + public LiveData getAccessibilityPermission() { return mAccessibilityPermission; } - public MutableLiveData getPowerOptimization() { + public LiveData getPowerOptimization() { return mPowerOptimization; } + + public void refreshServiceStatus(Context context) { + mAccessibilityPermission.setValue(TouchHelperService.isServiceRunning()); + if (context != null) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + boolean hasIgnored = pm != null && pm.isIgnoringBatteryOptimizations(context.getPackageName()); + mPowerOptimization.setValue(hasIgnored); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/zfdang/touchhelper/ui/settings/SettingsFragment.java b/app/src/main/java/com/zfdang/touchhelper/ui/settings/SettingsFragment.java index e1cdaec..d059667 100644 --- a/app/src/main/java/com/zfdang/touchhelper/ui/settings/SettingsFragment.java +++ b/app/src/main/java/com/zfdang/touchhelper/ui/settings/SettingsFragment.java @@ -52,14 +52,12 @@ import java.util.Map; import java.util.Set; -import static android.content.Context.WINDOW_SERVICE; - public class SettingsFragment extends PreferenceFragmentCompat { private final String TAG = getClass().getName(); LayoutInflater inflater; PackageManager packageManager; - WindowManager winManager; + private AlertDialog whitelistDialog; Settings mSetting; @@ -76,25 +74,17 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { initPreferences(); - winManager = (WindowManager) getActivity().getSystemService(WINDOW_SERVICE); - inflater = (LayoutInflater)getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); - packageManager = getActivity().getPackageManager(); + inflater = (LayoutInflater) requireActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + packageManager = requireActivity().getPackageManager(); } @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = super.onCreateView(inflater, container, savedInstanceState); - - // get the height of BottomNavigationView - int resourceId = getResources().getIdentifier("design_bottom_navigation_height", "dimen", getActivity().getPackageName()); - int height = 147; - if (resourceId > 0) { - height = getResources().getDimensionPixelSize(resourceId); + public void onDestroyView() { + if (whitelistDialog != null && whitelistDialog.isShowing()) { + whitelistDialog.dismiss(); + whitelistDialog = null; } - - // set bottom padding for the preference fragment, so that all parts could be shown properly - view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom() + height); - return view; + super.onDestroyView(); } private void initPreferences() { @@ -214,25 +204,14 @@ public boolean onPreferenceClick(Preference preference) { activity_widgets.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - HashSet results = (HashSet) newValue; -// Log.d(TAG, "size " + results.size()); - - // update activity widgets - Set keys = new HashSet<>(mapActivityWidgets.keySet()); - for(String key: keys){ - if(!results.contains(key)) { - // this key is not selected to keep, remove the entry - mapActivityWidgets.remove(key); - } + if (newValue instanceof Set) { + @SuppressWarnings("unchecked") + Set results = (Set) newValue; + mapActivityWidgets.keySet().retainAll(results); + Settings.getInstance().setPackageWidgets(mapActivityWidgets); + updateMultiSelectListPreferenceEntries(activity_widgets, mapActivityWidgets.keySet()); + TouchHelperService.dispatchAction(TouchHelperService.ACTION_REFRESH_CUSTOMIZED_ACTIVITY); } - Settings.getInstance().setPackageWidgets(mapActivityWidgets); - - // refresh MultiSelectListPreference - updateMultiSelectListPreferenceEntries(activity_widgets, mapActivityWidgets.keySet()); - - // send message to accessibility service - TouchHelperService.dispatchAction(TouchHelperService.ACTION_REFRESH_CUSTOMIZED_ACTIVITY); - return true; } }); @@ -261,25 +240,14 @@ public boolean onPreferenceClick(Preference preference) { activity_positions.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - HashSet results = (HashSet) newValue; -// Log.d(TAG, "size " + results.size()); - - // update activity widgets - Set keys = new HashSet<>(mapActivityPositions.keySet()); - for(String key: keys){ - if(!results.contains(key)) { - // this key is not selected to keep, remove the entry - mapActivityPositions.remove(key); - } + if (newValue instanceof Set) { + @SuppressWarnings("unchecked") + Set results = (Set) newValue; + mapActivityPositions.keySet().retainAll(results); + Settings.getInstance().setPackagePositions(mapActivityPositions); + updateMultiSelectListPreferenceEntries(activity_positions, mapActivityPositions.keySet()); + TouchHelperService.dispatchAction(TouchHelperService.ACTION_REFRESH_CUSTOMIZED_ACTIVITY); } - Settings.getInstance().setPackagePositions(mapActivityPositions); - - // refresh MultiSelectListPreference - updateMultiSelectListPreferenceEntries(activity_positions, mapActivityPositions.keySet()); - - // send message to accessibility service - TouchHelperService.dispatchAction(TouchHelperService.ACTION_REFRESH_CUSTOMIZED_ACTIVITY); - return true; } }); @@ -355,13 +323,13 @@ public View getView(int position, View convertView, ViewGroup parent) { } }); - final AlertDialog dialog = new AlertDialog.Builder(requireContext()) + whitelistDialog = new AlertDialog.Builder(requireContext()) .setView(viewAppList) .create(); Button btCancel = viewAppList.findViewById(R.id.button_cancel); if (btCancel != null) { - btCancel.setOnClickListener(view -> dialog.dismiss()); + btCancel.setOnClickListener(view -> whitelistDialog.dismiss()); } Button btConfirm = viewAppList.findViewById(R.id.button_confirm); if (btConfirm != null) { @@ -374,11 +342,11 @@ public View getView(int position, View convertView, ViewGroup parent) { } mSetting.setWhitelistPackages(pkgWhitelist); TouchHelperService.dispatchAction(TouchHelperService.ACTION_REFRESH_PACKAGE); - dialog.dismiss(); + whitelistDialog.dismiss(); }); } - dialog.show(); + whitelistDialog.show(); } static class AppInformation implements Comparable { diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 08d05f9..2854b9d 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -66,7 +66,7 @@ android:layout_marginTop="2dp" android:layout_marginBottom="8dp" android:src="@drawable/ic_configuration" - android:text="@string/button_setting" + android:contentDescription="@string/button_setting" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/text_accessibility_permission" /> @@ -75,6 +75,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_wrong" + android:contentDescription="@string/text_accessibility_permission" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@+id/guideline_accessibility_permission" @@ -119,7 +120,7 @@ android:layout_marginTop="2dp" android:layout_marginBottom="8dp" android:src="@drawable/ic_configuration" - android:text="@string/button_setting" + android:contentDescription="@string/button_setting" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/text_power_permission" /> @@ -128,6 +129,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_wrong" + android:contentDescription="@string/text_power_permission" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@+id/guideline_power_permission" diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml deleted file mode 100644 index c0a7349..0000000 --- a/app/src/main/res/layout/fragment_settings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 78b6e8b..f7edcda 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -14,8 +14,7 @@ + android:label="@string/title_settings" /> Date: Mon, 19 Oct 2026 17:22:53 +0800 Subject: [PATCH 09/10] test: add comprehensive unit tests for models, settings and rule matching --- .../PackagePositionDescriptionTest.java | 91 ++++++++++++ .../com/zfdang/touchhelper/SettingsTest.java | 140 ++++++++++++++++++ .../zfdang/touchhelper/SkipAdRulesTest.java | 27 ++++ 3 files changed, 258 insertions(+) create mode 100644 app/src/test/java/com/zfdang/touchhelper/PackagePositionDescriptionTest.java create mode 100644 app/src/test/java/com/zfdang/touchhelper/SettingsTest.java diff --git a/app/src/test/java/com/zfdang/touchhelper/PackagePositionDescriptionTest.java b/app/src/test/java/com/zfdang/touchhelper/PackagePositionDescriptionTest.java new file mode 100644 index 0000000..8e7c393 --- /dev/null +++ b/app/src/test/java/com/zfdang/touchhelper/PackagePositionDescriptionTest.java @@ -0,0 +1,91 @@ +package com.zfdang.touchhelper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class PackagePositionDescriptionTest { + + @Test + public void defaultConstructor_initializesDefaultFields() { + PackagePositionDescription desc = new PackagePositionDescription(); + assertNotNull(desc.packageName); + assertNotNull(desc.activityName); + assertEquals("", desc.packageName); + assertEquals("", desc.activityName); + assertEquals(0, desc.x); + assertEquals(0, desc.y); + } + + @Test + public void parameterizedConstructor_handlesNullsSafely() { + PackagePositionDescription desc = new PackagePositionDescription(null, null, 100, 200); + assertEquals("", desc.packageName); + assertEquals("", desc.activityName); + assertEquals(100, desc.x); + assertEquals(200, desc.y); + } + + @Test + public void copyConstructor_clonesFieldsAccurately() { + PackagePositionDescription original = new PackagePositionDescription("com.example.app", "com.example.app.MainActivity", 150, 250); + PackagePositionDescription copy = new PackagePositionDescription(original); + + assertEquals("com.example.app", copy.packageName); + assertEquals("com.example.app.MainActivity", copy.activityName); + assertEquals(150, copy.x); + assertEquals(250, copy.y); + assertEquals(original, copy); + assertEquals(original.hashCode(), copy.hashCode()); + } + + @Test + public void copyConstructor_handlesNullArgument() { + PackagePositionDescription copy = new PackagePositionDescription(null); + assertEquals("", copy.packageName); + assertEquals("", copy.activityName); + assertEquals(0, copy.x); + assertEquals(0, copy.y); + } + + @Test + public void equalsAndHashCode_satisfyContract() { + PackagePositionDescription desc1 = new PackagePositionDescription("pkg", "act", 10, 20); + PackagePositionDescription desc2 = new PackagePositionDescription("pkg", "act", 10, 20); + PackagePositionDescription descDiffPkg = new PackagePositionDescription("pkg2", "act", 10, 20); + PackagePositionDescription descDiffAct = new PackagePositionDescription("pkg", "act2", 10, 20); + PackagePositionDescription descDiffX = new PackagePositionDescription("pkg", "act", 11, 20); + PackagePositionDescription descDiffY = new PackagePositionDescription("pkg", "act", 10, 21); + + // Reflexive + assertEquals(desc1, desc1); + // Symmetric + assertEquals(desc1, desc2); + assertEquals(desc2, desc1); + assertEquals(desc1.hashCode(), desc2.hashCode()); + + // Null and different type + assertNotEquals(null, desc1); + assertNotEquals("string", desc1); + + // Difference in each field + assertNotEquals(desc1, descDiffPkg); + assertNotEquals(desc1, descDiffAct); + assertNotEquals(desc1, descDiffX); + assertNotEquals(desc1, descDiffY); + } + + @Test + public void toString_containsFieldValues() { + PackagePositionDescription desc = new PackagePositionDescription("com.test", "MainActivity", 50, 75); + String s = desc.toString(); + assertTrue(s.contains("com.test")); + assertTrue(s.contains("MainActivity")); + assertTrue(s.contains("50")); + assertTrue(s.contains("75")); + } +} diff --git a/app/src/test/java/com/zfdang/touchhelper/SettingsTest.java b/app/src/test/java/com/zfdang/touchhelper/SettingsTest.java new file mode 100644 index 0000000..5e69f68 --- /dev/null +++ b/app/src/test/java/com/zfdang/touchhelper/SettingsTest.java @@ -0,0 +1,140 @@ +package com.zfdang.touchhelper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Rect; + +import com.zfdang.TouchHelperApp; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) +public class SettingsTest { + + private Settings settings; + + @Before + public void setUp() { + settings = Settings.getInstance(); + } + + @Test + public void getInstance_returnsNonNullSingleton() { + assertNotNull(settings); + Settings second = Settings.getInstance(); + assertTrue(settings == second); + } + + @Test + public void getPackageWidgets_returnsDefensiveCopy() { + Map> widgets = new HashMap<>(); + Set set = new HashSet<>(); + PackageWidgetDescription desc = new PackageWidgetDescription("com.test", "Act", "Cls", "id", "desc", "text", new Rect(0, 0, 10, 10), true, false); + set.add(desc); + widgets.put("com.test", set); + + settings.setPackageWidgets(widgets); + + Map> retrieved = settings.getPackageWidgets(); + assertNotNull(retrieved); + assertTrue(retrieved.containsKey("com.test")); + + // Modifying retrieved map should not affect internal state + try { + retrieved.clear(); + } catch (UnsupportedOperationException ignored) { + // Unmodifiable map is also acceptable + } + + Map> retrievedAgain = settings.getPackageWidgets(); + assertTrue(retrievedAgain.containsKey("com.test")); + } + + @Test + public void getPackagePositions_returnsDefensiveCopy() { + Map positions = new HashMap<>(); + positions.put("com.test", new PackagePositionDescription("com.test", "Act", 100, 200)); + + settings.setPackagePositions(positions); + + Map retrieved = settings.getPackagePositions(); + assertNotNull(retrieved); + assertTrue(retrieved.containsKey("com.test")); + + try { + retrieved.clear(); + } catch (UnsupportedOperationException ignored) { + } + + Map retrievedAgain = settings.getPackagePositions(); + assertTrue(retrievedAgain.containsKey("com.test")); + } + + @Test + public void getWhitelistPackages_returnsDefensiveCopy() { + Set whitelist = new HashSet<>(); + whitelist.add("com.example.one"); + whitelist.add("com.example.two"); + + settings.setWhitelistPackages(whitelist); + + Set retrieved = settings.getWhitelistPackages(); + retrieved.add("com.example.three"); + + Set retrievedAgain = settings.getWhitelistPackages(); + assertFalse(retrievedAgain.contains("com.example.three")); + } + + @Test + public void initSettings_handlesCorruptedJsonGracefully() throws Exception { + Context context = TouchHelperApp.getAppContext(); + SharedPreferences pref = context.getSharedPreferences("TouchHelper_Config", Activity.MODE_PRIVATE); + pref.edit() + .putString("PACKAGE_WIDGETS", "{invalid-json-data:::") + .putString("PACKAGE_POSITIONS", "{\"invalid\"::broken}") + .apply(); + + // Create a new Settings instance via reflection to trigger initSettings() + Constructor constructor = Settings.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Settings corruptedSettings = constructor.newInstance(); + + assertNotNull(corruptedSettings.getPackageWidgets()); + assertNotNull(corruptedSettings.getPackagePositions()); + assertTrue(corruptedSettings.getPackageWidgets().isEmpty()); + assertTrue(corruptedSettings.getPackagePositions().isEmpty()); + } + + @Test + public void skipAdPreferences_canBeToggledAndPersisted() { + settings.setSkipAdNotification(false); + assertFalse(settings.isSkipAdNotification()); + settings.setSkipAdNotification(true); + assertTrue(settings.isSkipAdNotification()); + + settings.setSkipAdDuration(6); + assertEquals(6, settings.getSkipAdDuration()); + + settings.setDisclosureAccepted(true); + assertTrue(settings.isDisclosureAccepted()); + } +} diff --git a/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java b/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java index eee7804..e46fa48 100644 --- a/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java @@ -180,4 +180,31 @@ public void widgetDescription_equalsDifferentiatesByIdAndTextEvenIfPositionMatch assertEquals(a, copy); assertEquals(a.hashCode(), copy.hashCode()); } + + @Test + public void parseKeywords_handlesUnicodeAndFullWidthWhitespace() { + // \u3000 is Chinese full-width ideographic space + List result = SkipAdRules.parseKeywords("\u3000跳过\u3000\u3000Skip \t关闭\u3000"); + assertEquals(Arrays.asList("跳过", "Skip", "关闭"), result); + } + + @Test + public void findKeyword_caseInsensitiveMatching() { + assertEquals("Skip", SkipAdRules.findKeyword("SKIP AD", null, KEYWORDS, SELF)); + assertEquals("Skip", SkipAdRules.findKeyword("skip 5s", null, KEYWORDS, SELF)); + } + + @Test + public void findKeyword_stripsZeroWidthCharacters() { + // Ad inserting zero-width space (\u200B) or zero-width joiner (\u200D) to evade keyword detection + assertEquals("跳过", SkipAdRules.findKeyword("跳\u200B过\u200D广告", null, KEYWORDS, SELF)); + } + + @Test + public void findWidget_emptyPositionDoesNotMatchOffscreenZeroBounds() { + // Rule configured only by idName with position (0, 0, 0, 0) + PackageWidgetDescription rule = rule(0, 0, 0, 0, "com.app:id/skip", "", ""); + // A node with bounds (0, 0, 0, 0) but different id must NOT match bounds + assertNull(SkipAdRules.findWidget(0, 0, 0, 0, "com.app:id/other", null, null, Collections.singleton(rule))); + } } From cf21a18da7b2eb4792c83141e07a78e67e10e961 Mon Sep 17 00:00:00 2001 From: dantifer Date: Mon, 19 Oct 2026 19:51:10 +0800 Subject: [PATCH 10/10] ui: modernize home tab and app aesthetic with Material 3 card architecture --- .../touchhelper/ui/home/HomeFragment.java | 187 +++-- .../main/res/color/bottom_nav_item_color.xml | 5 + app/src/main/res/drawable/bg_badge_action.xml | 11 + .../main/res/drawable/bg_badge_running.xml | 11 + .../main/res/drawable/bg_badge_stopped.xml | 11 + .../res/drawable/bg_icon_circle_amber.xml | 5 + .../main/res/drawable/bg_icon_circle_blue.xml | 5 + .../main/res/drawable/bg_icon_circle_gray.xml | 5 + .../res/drawable/bg_icon_circle_green.xml | 5 + .../main/res/drawable/bg_tag_recommended.xml | 11 + app/src/main/res/drawable/bg_tag_required.xml | 11 + .../main/res/drawable/ic_accessibility_24.xml | 9 + .../main/res/drawable/ic_battery_saver_24.xml | 9 + .../main/res/drawable/ic_check_circle_24.xml | 9 + .../main/res/drawable/ic_chevron_right_24.xml | 9 + .../main/res/drawable/ic_lock_outline_24.xml | 9 + .../main/res/drawable/ic_open_in_new_24.xml | 9 + app/src/main/res/drawable/ic_right.xml | 11 +- .../main/res/drawable/ic_shield_alert_24.xml | 9 + .../main/res/drawable/ic_shield_check_24.xml | 9 + app/src/main/res/drawable/ic_wrong.xml | 11 +- app/src/main/res/layout/activity_main.xml | 24 +- app/src/main/res/layout/fragment_about.xml | 216 +++--- app/src/main/res/layout/fragment_home.xml | 675 +++++++++++++----- app/src/main/res/values/colors.xml | 52 +- app/src/main/res/values/strings.xml | 53 +- app/src/main/res/values/styles.xml | 44 +- .../zfdang/touchhelper/HomeViewModelTest.java | 39 + 28 files changed, 1127 insertions(+), 337 deletions(-) create mode 100644 app/src/main/res/color/bottom_nav_item_color.xml create mode 100644 app/src/main/res/drawable/bg_badge_action.xml create mode 100644 app/src/main/res/drawable/bg_badge_running.xml create mode 100644 app/src/main/res/drawable/bg_badge_stopped.xml create mode 100644 app/src/main/res/drawable/bg_icon_circle_amber.xml create mode 100644 app/src/main/res/drawable/bg_icon_circle_blue.xml create mode 100644 app/src/main/res/drawable/bg_icon_circle_gray.xml create mode 100644 app/src/main/res/drawable/bg_icon_circle_green.xml create mode 100644 app/src/main/res/drawable/bg_tag_recommended.xml create mode 100644 app/src/main/res/drawable/bg_tag_required.xml create mode 100644 app/src/main/res/drawable/ic_accessibility_24.xml create mode 100644 app/src/main/res/drawable/ic_battery_saver_24.xml create mode 100644 app/src/main/res/drawable/ic_check_circle_24.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right_24.xml create mode 100644 app/src/main/res/drawable/ic_lock_outline_24.xml create mode 100644 app/src/main/res/drawable/ic_open_in_new_24.xml create mode 100644 app/src/main/res/drawable/ic_shield_alert_24.xml create mode 100644 app/src/main/res/drawable/ic_shield_check_24.xml create mode 100644 app/src/test/java/com/zfdang/touchhelper/HomeViewModelTest.java diff --git a/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java b/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java index 6ce12bf..26b86c6 100644 --- a/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java +++ b/app/src/main/java/com/zfdang/touchhelper/ui/home/HomeFragment.java @@ -3,26 +3,23 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; -import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; import com.zfdang.touchhelper.R; import com.zfdang.touchhelper.TouchHelperService; import com.zfdang.touchhelper.Utilities; @@ -32,71 +29,153 @@ public class HomeFragment extends Fragment { private final String TAG = getClass().getName(); private HomeViewModel homeViewModel; + private AlertDialog disclosureDialog; + private AlertDialog tipDialog; + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { homeViewModel = new ViewModelProvider(this).get(HomeViewModel.class); View root = inflater.inflate(R.layout.fragment_home, container, false); - final Drawable drawableYes = ContextCompat.getDrawable(getContext(), R.drawable.ic_right); - final Drawable drawableNo = ContextCompat.getDrawable(getContext(), R.drawable.ic_wrong); - - // set observers for widget - final ImageView imageAccessibilityPermission = root.findViewById(R.id.image_accessibility_permission); - homeViewModel.getAccessibilityPermission().observe(getViewLifecycleOwner(), new Observer() { - @Override - public void onChanged(Boolean aBoolean) { - if(aBoolean) { - imageAccessibilityPermission.setImageDrawable(drawableYes); - } else { - imageAccessibilityPermission.setImageDrawable(drawableNo); - } + // 1. Hero Status Card + final MaterialCardView cardServiceStatus = root.findViewById(R.id.card_service_status); + final ImageView imageServiceStatusHero = root.findViewById(R.id.image_service_status_hero); + final TextView textServiceStatusTitle = root.findViewById(R.id.text_service_status_title); + final TextView textServiceStatusDesc = root.findViewById(R.id.text_service_status_desc); + final TextView statusBadgeHero = root.findViewById(R.id.status_badge_hero); + final MaterialButton buttonStartService = root.findViewById(R.id.button_start_service); + + // 2. Checklist items & badges + final MaterialCardView cardAccessibility = root.findViewById(R.id.card_accessibility_permission); + final TextView textAccessibilityBadge = root.findViewById(R.id.text_accessibility_badge); + + final MaterialCardView cardPower = root.findViewById(R.id.card_power_permission); + final TextView textPowerBadge = root.findViewById(R.id.text_power_badge); + + final MaterialCardView cardRomBattery = root.findViewById(R.id.card_rom_battery); + final MaterialCardView cardTaskLock = root.findViewById(R.id.card_task_lock); + final MaterialCardView cardHelpLink = root.findViewById(R.id.card_help_link); + + // Observe Accessibility status + homeViewModel.getAccessibilityPermission().observe(getViewLifecycleOwner(), isRunning -> { + Context context = getContext(); + if (context == null) return; + + boolean running = Boolean.TRUE.equals(isRunning); + if (running) { + // Running state + cardServiceStatus.setCardBackgroundColor(ContextCompat.getColor(context, R.color.status_running_bg)); + cardServiceStatus.setStrokeColor(ContextCompat.getColor(context, R.color.status_running_stroke)); + imageServiceStatusHero.setImageResource(R.drawable.ic_shield_check_24); + textServiceStatusTitle.setText(R.string.status_hero_running_title); + textServiceStatusTitle.setTextColor(ContextCompat.getColor(context, R.color.status_running_text)); + textServiceStatusDesc.setText(R.string.status_hero_running_desc); + textServiceStatusDesc.setTextColor(ContextCompat.getColor(context, R.color.status_running_text)); + + statusBadgeHero.setText(R.string.status_pill_running); + statusBadgeHero.setBackgroundResource(R.drawable.bg_badge_running); + statusBadgeHero.setTextColor(ContextCompat.getColor(context, R.color.status_running_badge_text)); + + buttonStartService.setVisibility(View.GONE); + + textAccessibilityBadge.setText(R.string.status_badge_enabled); + textAccessibilityBadge.setBackgroundResource(R.drawable.bg_badge_running); + textAccessibilityBadge.setTextColor(ContextCompat.getColor(context, R.color.status_running_badge_text)); + } else { + // Stopped state + cardServiceStatus.setCardBackgroundColor(ContextCompat.getColor(context, R.color.status_stopped_bg)); + cardServiceStatus.setStrokeColor(ContextCompat.getColor(context, R.color.status_stopped_stroke)); + imageServiceStatusHero.setImageResource(R.drawable.ic_shield_alert_24); + textServiceStatusTitle.setText(R.string.status_hero_stopped_title); + textServiceStatusTitle.setTextColor(ContextCompat.getColor(context, R.color.status_stopped_text)); + textServiceStatusDesc.setText(R.string.status_hero_stopped_desc); + textServiceStatusDesc.setTextColor(ContextCompat.getColor(context, R.color.status_stopped_text)); + + statusBadgeHero.setText(R.string.status_pill_stopped); + statusBadgeHero.setBackgroundResource(R.drawable.bg_badge_stopped); + statusBadgeHero.setTextColor(ContextCompat.getColor(context, R.color.status_stopped_badge_text)); + + buttonStartService.setVisibility(View.VISIBLE); + + textAccessibilityBadge.setText(R.string.status_badge_enable); + textAccessibilityBadge.setBackgroundResource(R.drawable.bg_badge_action); + textAccessibilityBadge.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)); } }); - final ImageView imagePowerPermission = root.findViewById(R.id.image_power_permission); - homeViewModel.getPowerOptimization().observe(getViewLifecycleOwner(), new Observer() { - @Override - public void onChanged(Boolean aBoolean) { - if(aBoolean) { - imagePowerPermission.setImageDrawable(drawableYes); - } else { - imagePowerPermission.setImageDrawable(drawableNo); - } + // Observe Battery Optimization status + homeViewModel.getPowerOptimization().observe(getViewLifecycleOwner(), isOptimized -> { + Context context = getContext(); + if (context == null) return; + + boolean optimized = Boolean.TRUE.equals(isOptimized); + if (optimized) { + textPowerBadge.setText(R.string.status_badge_optimized); + textPowerBadge.setBackgroundResource(R.drawable.bg_badge_running); + textPowerBadge.setTextColor(ContextCompat.getColor(context, R.color.status_running_badge_text)); + } else { + textPowerBadge.setText(R.string.status_badge_optimize); + textPowerBadge.setBackgroundResource(R.drawable.bg_badge_action); + textPowerBadge.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)); } }); + // Listeners + buttonStartService.setOnClickListener(v -> showAccessibilityDisclosure()); + cardAccessibility.setOnClickListener(v -> showAccessibilityDisclosure()); - // set listener for buttons - final ImageButton btAccessibilityPermission = root.findViewById(R.id.button_accessibility_permission); - btAccessibilityPermission.setOnClickListener(v -> showAccessibilityDisclosure()); - - final ImageButton btPowerPermission = root.findViewById(R.id.button_power_permission); - btPowerPermission.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - // 打开电池优化的界面,让用户设置 - try { - Intent intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); - startActivity(intent); - } catch (ActivityNotFoundException e) { - Utilities.toast("未找到系统电池优化设置页面!"); - } - } - }); + cardPower.setOnClickListener(v -> openBatteryOptimization()); + + cardRomBattery.setOnClickListener(v -> showTipDialog( + R.string.dialog_rom_battery_title, + R.string.dialog_rom_battery_message)); + + cardTaskLock.setOnClickListener(v -> showTipDialog( + R.string.dialog_task_lock_title, + R.string.dialog_task_lock_message)); + + cardHelpLink.setOnClickListener(v -> openWebUrl("https://touchhelper.zfdang.com/enable")); - // get the service status + // Refresh state checkServiceStatus(); return root; } + private void openBatteryOptimization() { + try { + Intent intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); + startActivity(intent); + } catch (ActivityNotFoundException e) { + Utilities.toast("未找到系统电池优化设置页面!"); + } + } + + private void openWebUrl(String url) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + startActivity(intent); + } catch (Exception e) { + Utilities.toast("无法打开浏览器: " + url); + } + } + + private void showTipDialog(int titleRes, int messageRes) { + if (tipDialog != null && tipDialog.isShowing()) { + tipDialog.dismiss(); + } + tipDialog = new AlertDialog.Builder(requireContext()) + .setTitle(titleRes) + .setMessage(messageRes) + .setPositiveButton(R.string.dialog_ok, null) + .show(); + } + @Override public void onResume() { checkServiceStatus(); super.onResume(); - // the service was enabled from the system settings (or before the disclosure existed) - // but the user has not confirmed the disclosure yet: it stays idle until they do if (TouchHelperService.isServiceRunning() && !com.zfdang.touchhelper.Settings.getInstance().isDisclosureAccepted()) { showAccessibilityDisclosure(); @@ -108,14 +187,12 @@ public void onPause() { if (disclosureDialog != null && disclosureDialog.isShowing()) { disclosureDialog.dismiss(); } + if (tipDialog != null && tipDialog.isShowing()) { + tipDialog.dismiss(); + } super.onPause(); } - /** - * Google Play requires a prominent in-app disclosure before an app sends the user to enable - * an accessibility service that is not an assistive tool: what the service reads, what it - * does with it, and that nothing leaves the device. - */ private void showAccessibilityDisclosure() { if (com.zfdang.touchhelper.Settings.getInstance().isDisclosureAccepted()) { openAccessibilitySettings(); @@ -139,8 +216,6 @@ private void showAccessibilityDisclosure() { .show(); } - private AlertDialog disclosureDialog; - private void openAccessibilitySettings() { try { Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS); @@ -151,7 +226,7 @@ private void openAccessibilitySettings() { } } - public void checkServiceStatus(){ + public void checkServiceStatus() { Context context = getContext(); if (context != null) { homeViewModel.refreshServiceStatus(context.getApplicationContext()); diff --git a/app/src/main/res/color/bottom_nav_item_color.xml b/app/src/main/res/color/bottom_nav_item_color.xml new file mode 100644 index 0000000..6f1b65f --- /dev/null +++ b/app/src/main/res/color/bottom_nav_item_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_badge_action.xml b/app/src/main/res/drawable/bg_badge_action.xml new file mode 100644 index 0000000..dbd12b0 --- /dev/null +++ b/app/src/main/res/drawable/bg_badge_action.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_badge_running.xml b/app/src/main/res/drawable/bg_badge_running.xml new file mode 100644 index 0000000..ce80d8e --- /dev/null +++ b/app/src/main/res/drawable/bg_badge_running.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_badge_stopped.xml b/app/src/main/res/drawable/bg_badge_stopped.xml new file mode 100644 index 0000000..54fa5a4 --- /dev/null +++ b/app/src/main/res/drawable/bg_badge_stopped.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_icon_circle_amber.xml b/app/src/main/res/drawable/bg_icon_circle_amber.xml new file mode 100644 index 0000000..fd7aa19 --- /dev/null +++ b/app/src/main/res/drawable/bg_icon_circle_amber.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_icon_circle_blue.xml b/app/src/main/res/drawable/bg_icon_circle_blue.xml new file mode 100644 index 0000000..ac06f97 --- /dev/null +++ b/app/src/main/res/drawable/bg_icon_circle_blue.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_icon_circle_gray.xml b/app/src/main/res/drawable/bg_icon_circle_gray.xml new file mode 100644 index 0000000..1635a76 --- /dev/null +++ b/app/src/main/res/drawable/bg_icon_circle_gray.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_icon_circle_green.xml b/app/src/main/res/drawable/bg_icon_circle_green.xml new file mode 100644 index 0000000..e015d23 --- /dev/null +++ b/app/src/main/res/drawable/bg_icon_circle_green.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_tag_recommended.xml b/app/src/main/res/drawable/bg_tag_recommended.xml new file mode 100644 index 0000000..2257ce6 --- /dev/null +++ b/app/src/main/res/drawable/bg_tag_recommended.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_tag_required.xml b/app/src/main/res/drawable/bg_tag_required.xml new file mode 100644 index 0000000..cb5de87 --- /dev/null +++ b/app/src/main/res/drawable/bg_tag_required.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_accessibility_24.xml b/app/src/main/res/drawable/ic_accessibility_24.xml new file mode 100644 index 0000000..288e41f --- /dev/null +++ b/app/src/main/res/drawable/ic_accessibility_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery_saver_24.xml b/app/src/main/res/drawable/ic_battery_saver_24.xml new file mode 100644 index 0000000..56de294 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_saver_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle_24.xml b/app/src/main/res/drawable/ic_check_circle_24.xml new file mode 100644 index 0000000..6ced4e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right_24.xml b/app/src/main/res/drawable/ic_chevron_right_24.xml new file mode 100644 index 0000000..eb3b053 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock_outline_24.xml b/app/src/main/res/drawable/ic_lock_outline_24.xml new file mode 100644 index 0000000..bf75439 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_in_new_24.xml b/app/src/main/res/drawable/ic_open_in_new_24.xml new file mode 100644 index 0000000..0e1fe2a --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_right.xml b/app/src/main/res/drawable/ic_right.xml index e4d6e96..b582518 100644 --- a/app/src/main/res/drawable/ic_right.xml +++ b/app/src/main/res/drawable/ic_right.xml @@ -1,4 +1,9 @@ - - + + diff --git a/app/src/main/res/drawable/ic_shield_alert_24.xml b/app/src/main/res/drawable/ic_shield_alert_24.xml new file mode 100644 index 0000000..f8dde80 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_alert_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shield_check_24.xml b/app/src/main/res/drawable/ic_shield_check_24.xml new file mode 100644 index 0000000..4a079a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wrong.xml b/app/src/main/res/drawable/ic_wrong.xml index 2755389..9f9cdbb 100644 --- a/app/src/main/res/drawable/ic_wrong.xml +++ b/app/src/main/res/drawable/ic_wrong.xml @@ -1,4 +1,9 @@ - - + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4c7c55d..d9bc3f6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,15 +4,16 @@ android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/page_background" android:fitsSystemWindows="true"> + + - - - - - - - - - - - - - - - + android:gravity="center_horizontal" + android:orientation="vertical" + android:paddingStart="16dp" + android:paddingTop="28dp" + android:paddingEnd="16dp" + android:paddingBottom="24dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 2854b9d..b853916 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,234 +1,561 @@ - - - - - - + + + + android:layout_height="wrap_content" + app:cardBackgroundColor="@color/status_running_bg" + app:strokeColor="@color/status_running_stroke"> - + android:orientation="vertical" + android:padding="20dp"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + android:layout_marginStart="10dp" + android:layout_weight="1" + android:text="@string/banner_privacy_desc" + android:textColor="@color/status_info_text" + android:textSize="12sp" + android:lineSpacingExtra="2dp" /> - + - + - + + - + + + + + + + + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:clickable="true" + android:focusable="true"> - - - + + + + + + + + + + + + + + + + + + + + + + + + + android:background="@drawable/bg_badge_action" + android:text="@string/status_badge_enable" + android:textColor="@color/colorPrimary" + android:textSize="12sp" + android:textStyle="bold" /> - + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + android:background="@drawable/bg_badge_action" + android:text="@string/status_badge_optimize" + android:textColor="@color/colorPrimary" + android:textSize="12sp" + android:textStyle="bold" /> - + + + + + + + + + + + + + + + + android:layout_marginStart="14dp" + android:layout_marginEnd="8dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + - + - + - + + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:clickable="true" + android:focusable="true"> - + android:gravity="center_vertical" + android:orientation="horizontal" + android:padding="16dp"> - + - + + + + + android:layout_marginStart="14dp" + android:layout_marginEnd="8dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + - + - + - + + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:clickable="true" + android:focusable="true"> - - - - - + + + + + + + + + android:layout_marginStart="14dp" + android:layout_marginEnd="8dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + + - + - + - + - - - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d744906..5752f81 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,52 @@ - #6200EE - #3700B3 - #03DAC5 + + #2563EB + #1D4ED8 + #DBEAFE + #2563EB - #25000000 - #ff000000 + + #F8FAFC + #FFFFFF + #E2E8F0 + #15000000 + + #0F172A + #475569 + #94A3B8 + #0F172A + + + #ECFDF5 + #A7F3D0 + #065F46 + #D1FAE5 + #047857 + #10B981 + + + #FFFBEB + #FDE68A + #92400E + #FEF3C7 + #B45309 + #F59E0B + + + #EFF6FF + #BFDBFE + #1E40AF + #3B82F6 + + + #EFF6FF + #2563EB + + + #FEE2E2 + #DC2626 + #FEF3C7 + #D97706 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c380ce2..1f0c8d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,8 @@ 设置 关于 服务状态 + + \"开屏跳过\"基于安卓系统的无障碍服务来实现跳过自动广告功能,开源不联网,绝对安全!为保证程序能正常工作,请检查以下设置: 1. 在\"无障碍\"设置里,打开\"开屏跳过 - 无障碍服务\"。 2. 在\"电池优化\"设置里,设置\"开屏跳过\"为\"不优化\"。 @@ -11,8 +13,54 @@ 4. 在\"任务管理\"里,锁定\"开屏跳过\"。 更多信息,请访问网址: https://touchhelper.zfdang.com/enable - 设置 + + + 服务正常运行中 + 已就绪,正在后台自动跳过开屏广告 + 无障碍服务未开启 + 需开启无障碍服务,方可检测并自动点击跳过 + 立即开启服务 + + 运行中 + 未开启 + + 已开启 + 去开启 + 已忽略 + 去设置 + 查看说明 + + 开源安全 · 本地比对 · 零网络权限 + 不申请网络权限,界面匹配全部在本地执行,绝不收集或上传任何个人数据。 + + 系统配置与保活向导 + 完成以下设置,确保跳过服务常驻不被后台误杀 + + 无障碍服务 + 必须 + 核心权限,用于查找跳过按钮并模拟点击 + + 忽略电池优化 + 推荐 + 加入系统电池优化白名单,防止息屏后被休眠冻结 + + 后台耗电与自启动权限 + 允许应用后台活动、自启动与关联启动 + + 多任务界面锁定应用 + 在最近任务列表中将应用卡片下拉加锁,避免一键清理 + + 各机型详细图文保活教程 + https://touchhelper.zfdang.com/enable + + 后台耗电与自启动设置说明 + 各品牌系统的后台策略有所不同,建议进行如下设置:\n\n• OPPO / OnePlus (ColorOS):\n设置 -> 应用 -> 应用管理 -> 开屏跳过 -> 耗电管理 -> 允许唤醒前台、允许完全后台行为。\n\n• 小米 / Redmi (HyperOS / MIUI):\n设置 -> 应用设置 -> 自启动管理 -> 允许开屏跳过自启动;省电策略设为\"无限制\"。\n\n• VIVO / iQOO (OriginOS):\n设置 -> 电池 -> 后台耗电管理 -> 开屏跳过 -> 允许高耗电。\n\n• 华为 / 荣耀 (HarmonyOS / MagicOS):\n设置 -> 应用和服务 -> 应用启动管理 -> 开屏跳过 -> 关闭自动管理,开启允许自启动、允许后台活动。 + 多任务锁定说明 + 从屏幕底部上滑并停顿进入多任务(最近任务)界面:\n\n1. 找到\"开屏跳过\"卡片。\n2. 长按卡片、点击右上角小锁图标或下拉卡片,选择\"锁定\"。\n3. 锁定后卡片将出现小锁标记,在点击\"一键清理\"时不会被关闭。 + 我知道了 + + 开屏跳过-无障碍服务 \"开屏跳过\"是基于安卓系统的无障碍服务来实现自动跳过广告功能的,为了保证程序能正常工作,打开此服务。\n\n本程序为开源程序,不需要网络权限,不会收集或上传任何个人信息,请放心使用! @@ -22,14 +70,13 @@ \n2. 应用里的指定控件。当应用工作时,自动查找对应的控件,如果找到则自动点击; \n3. 应用里的指定位置。当应用工作时,自动点击对应的区域。 \n\n上述三种方法可以在\"设置\"里做具体设置。 - \n\n详细信息,请访问网站: https://touchhelper.zfdang.com/ 隐私政策:\nhttps://touchhelper.zfdang.com/privacy.html Copyright © 2022 Zhengfa Dang - 版本: %s (%d) + 版本: %1$s (%2$d) 开启无障碍服务前请了解 \"开屏跳过\"通过安卓无障碍服务读取当前屏幕上的界面内容(控件文字、描述和位置),用于识别并自动点击开屏广告的\"跳过\"按钮。\n\n· 自动识别时,这些内容只在本机内存中即时比对,不保存。\n· 当您使用\"添加程序的跳过方法\"时,您所选控件的文字、描述和坐标会作为规则保存在本机的应用数据中,可在设置中查看或删除。\n· 当您使用\"复制窗口控件\"时,当前窗口的控件信息会按您的操作复制到系统剪贴板。\n\n本程序不申请网络权限,不会收集或上传任何数据。同意后即可开启无障碍服务;未同意前服务不会读取任何界面内容。 同意并前往设置 diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 9abd15b..c117f3d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,17 +1,49 @@ - - - + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/zfdang/touchhelper/HomeViewModelTest.java b/app/src/test/java/com/zfdang/touchhelper/HomeViewModelTest.java new file mode 100644 index 0000000..b0fe376 --- /dev/null +++ b/app/src/test/java/com/zfdang/touchhelper/HomeViewModelTest.java @@ -0,0 +1,39 @@ +package com.zfdang.touchhelper; + +import static org.junit.Assert.assertNotNull; + +import android.content.Context; + +import com.zfdang.touchhelper.ui.home.HomeViewModel; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) +public class HomeViewModelTest { + + private HomeViewModel viewModel; + private Context context; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + viewModel = new HomeViewModel(); + } + + @Test + public void testLiveDataInitializationAndRefresh() { + assertNotNull(viewModel.getAccessibilityPermission()); + assertNotNull(viewModel.getPowerOptimization()); + + viewModel.refreshServiceStatus(context); + + assertNotNull(viewModel.getAccessibilityPermission().getValue()); + assertNotNull(viewModel.getPowerOptimization().getValue()); + } +}