diff --git a/LICENSE b/LICENSE
index 8546d22..56ef93d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,7 @@
MIT License
-Copyright (c) 2026 MeiYongAI
+Copyright (c) 2026 krolchonok
+Copyright (c) 2026 MeiYongAI (Toki modifications)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index f57bc0b..28a3468 100644
--- a/README.md
+++ b/README.md
@@ -1,93 +1,200 @@
-
+# Changelog
-Toki is an LSPosed module for TikTok, with a Material 3 interface and 57 language options.
+## 1.0.0 — 2026-09-30
-> **AI-generated project.** This module is developed with AI-generated code. It may contain errors; review changes and test carefully before relying on it.
+### English
-## Features
+- New module identity: `com.toki.lsposed`, version code `1`, with a new release signing certificate. This installs separately from `com.seepd.toki`; disable the 0.x module in LSPosed before enabling 1.0.0. Settings are not migrated automatically.
+- TikTok 47.0.3 and 46.8.3 adaptation, with method discovery and persistent results keyed to the host code and rules.
+- Feed filters for topic and creator recommendation cards, keywords, duration, counts, offline insertion and AI-labeled videos or photos. Ad filtering also covers creator-profile video lists; other content rules remain limited to For You.
+- Playback and clean-mode controls, progress-bar handling, translation and original/translated comment copying.
+- Material 3 interface with 57 language options, feature status, configuration import/export and donation-image saving.
+- Local validation: 252 unit tests passed; lint reported 0 errors and 28 warnings. Release builds use R8 and resource shrinking. The latest profile-ad changes still need device verification.
-- **Feed filters** — ads across video feeds, including creator profiles; recommendation filters for LIVE, photos, AI-labeled videos and photos, topic/creator cards, keywords, duration, views and likes. Block offline video insertion.
-- **Playback controls** — custom speeds, auto-hide controls, fullscreen playback, progress bar options and auto-scroll unlocking.
-- **Media & tools** — watermark-free downloads, custom save folders, audio restriction handling, translation options and comment text copying.
-- **Region settings** — region/SIM, language, time zone and location spoofing within TikTok; creator region display.
-- **Easy management** — feature status, settings import/export and automatic method discovery.
+### 中文
-## Get started
+- 新模块包名为 `com.toki.lsposed`,版本号 `1`,使用新的正式签名。与 `com.seepd.toki` 分开安装;请先在 LSPosed 中停用 0.x 模块,再启用 1.0.0,配置不会自动迁移。
+- 适配 TikTok 47.0.3、46.8.3,支持方法查找,并按宿主代码与规则标识持久保存结果。
+- 信息流过滤涵盖话题及创作者推荐卡片、关键词、时长、数量范围、离线插入,以及标记为 AI 生成的视频和照片。广告过滤扩展到作者主页的视频列表,其他内容规则仍只作用于推荐页。
+- 完善播放清屏、进度条处理、翻译与评论原文/译文复制。
+- Material 3 界面提供 57 种语言选项、功能状态查看、配置导入导出及赞助图片保存。
+- 本地验证:252 项单元测试通过,Lint 0 错误、28 警告;Release 启用 R8 与资源压缩。最新作者主页广告过滤调整仍待实机验证。
-**Requires:** Android 9 or newer, a working LSPosed installation supporting **libxposed API 102**, and TikTok. Toki does not work as a standalone app without the framework.
-**Adapted TikTok versions:** `47.0.3` · `46.8.3`. Builds from different stores may behave differently; other versions are not guaranteed to work.
+[English](#changelog) | [中文](#中文)
-1. Install Toki from [Releases](https://github.com/MeiYongAI/Toki/releases).
-2. Enable it in LSPosed and select your installed TikTok in the module scope.
-3. Open Toki and choose the features you want.
-4. Open TikTok. If a method-discovery dialog appears, let it finish. TikTok closes when discovery succeeds; open it again to apply the results.
+## 0.4.23
-Supported package names: `com.zhiliaoapp.musically` and `com.ss.android.ugc.trill`.
+- Added a Home dashboard as the default destination with LSPosed service status and the installed TikTok version.
+- Moved the Root-based TikTok restart action to Home and added Root-only cache clearing that preserves accounts, settings, drafts, and app data.
+- Added manual interface language switching between Follow System, English, and Chinese, plus an action to reset all Toki settings.
+- Added direct GitHub, Telegram, and issue-report links and expanded navigation to Home, General, Feed, and Downloads.
+- Added an independent page-purification control for game-related entrances.
-**Moving from 0.x:** Toki 1.0.0 uses a new module package, `io.github.meiyongai.toki`, and a new release signing key. It installs separately from `com.seepd.toki`; disable the previous module in LSPosed before enabling this one. Settings are not migrated automatically. Keep any needed configuration before uninstalling. See the [release notes](CHANGELOG.md).
+## 0.4.22
-## Community
+- Added an option to keep the video progress bar visible on standard videos shorter than 30 seconds; the existing hide-progress-bar option takes priority.
+- Added optional author-location display with the matching country flag and region code beside the author name.
+- Split author-avatar and author-information purification into independent controls.
+- Updated TikTok 46.4.3 purification paths for the LIVE entry, search entry, top and bottom navigation, music title, and video feedback surveys.
+- Added grouped purification controls for commercial and promotion labels, creative tools and templates, movie and anime entrances, sharing and creator incentives, and activity safety warnings.
+- Moved feed payload, label, anchor, survey, and warning gates into a dedicated hook module and removed stale decompiler aliases and obsolete warning paths.
-Join the [Telegram group](https://t.me/toki_lsposed) for discussion and feedback. When reporting a problem, include your Toki/TikTok versions, TikTok download source, steps to reproduce and relevant LSPosed logs. Remove personal information before sharing logs.
+## 0.4.21
-## Development
+- Added an optional page-purification control to hide the system status bar on TikTok's main video pages.
+- Applied the status-bar control only to TikTok 46.4.3's `MainActivity` and kept it active across layout updates.
+- Used Android's `WindowInsetsController` on Android 11+ with a legacy system-UI fallback for Android 8–10.
-### Requirements
+## 0.4.20
-- Android Studio with JDK 21 to run Gradle (Java source/target: 17)
-- Android SDK 37
-- Android Gradle Plugin 9.4.0 and Gradle 9.7.1 (provided by the wrapper)
+- Set official TikTok 46.4.3 as the sole implementation, testing, and maintenance target.
+- Other TikTok versions are outside the support scope and receive no version-specific compatibility work.
+- Removed 46.3.x download-location and comment-translation compatibility code.
+- Split hook installation into isolated feature classes while keeping one module APK and entry point.
+- Extended TikTok 46.4.3 For You filtering to feed dispatch and cache-backed list insertion paths.
+- Preserved the exact text entered in view-count and like-count range fields while keeping numeric filtering unchanged.
+- Added an option to disable offline cold-cache use when a network is available.
-The app targets Android 35 and supports Android 9 (API 28) and newer. Dependencies are resolved from Google Maven and Maven Central.
+## 0.4.19
-### Build and test
+- Added compatibility for the comment-translation control and playback-completion handling on official TikTok 46.4.3.
+- Fixed custom video, image, and GIF save locations on TikTok 46.3.2 and 46.4.3 by intercepting TikTok's MediaStore insertion bridge.
+- Replaced the Android system directory picker with a built-in relative shared-storage path editor.
+- Extended trending-topic and promotional-overlay purification for TikTok 46.4.3.
+- Known issue: view-count and like-count filtering remains unreliable on TikTok 46.4.3 and may let some out-of-range videos through.
-```bash
-./gradlew :app:assembleDebug
-./gradlew :app:testDebugUnitTest
-./gradlew :app:lintRelease
-./gradlew :app:assembleRelease
-```
+## 0.4.18
-On Windows, use `gradlew.bat`. The main source sets are `app/src/main/java`, `app/src/main/res` and `app/src/test/java`. Release builds enable R8 and resource shrinking. With the local signing configuration present, the signed APK is written to `app/build/outputs/apk/release/app-release.apk`.
+- Reworked the Material 3 settings screen and organized features into General, Feed, and Downloads sections.
+- Added page purification with optional controls for author details, descriptions, music, action buttons, search, Tako, translation controls, and navigation bars.
+- Added filters for AI-generated content, trending-topic bars, and content-rating prompts.
+- Added independent GPS, system-language, and system-time-zone spoofing that follows the selected target region.
+- Added an option to skip the startup login guide by dismissing skippable prompts only; it does not bypass login or verification.
+- Improved view-count and like-count filters with support for full numbers and `K`/`M`/`B` suffixes.
+- Removed the failed anti-burn-in feature; the standard default playback-speed option is now displayed as `1.0x`.
+- Clarified download wording to state that all videos prefer watermark-free URLs.
-For your own signed build, copy [keystore.properties.example](keystore/keystore.properties.example) to `keystore/keystore.properties` and supply your own keystore and credentials. Without that file, the release APK is unsigned. Set `JAVA_HOME` or Android Studio's Gradle JDK locally; no machine-specific JDK path is committed.
+## 0.4.17
-The release keystore is intentionally kept outside version control. Keep `keystore/toki-release.jks` and `keystore/keystore.properties` private and backed up; losing this key prevents future updates from being installed over the same app. The current public certificate fingerprint is:
+- Added support for official TikTok 46.3.2 and 46.3.3.
+- Fixed the comment translation button not executing translation on TikTok 46.3.2.
+- Adapted the anti-burn-in status Toast entry for TikTok 46.3.2.
-`SHA-256 74:09:5F:B8:C2:88:80:15:FC:DD:B9:3E:3C:14:F6:F6:3D:2A:EA:33:40:AB:5E:F9:6D:60:B0:C0:14:E1:6C:07`
+## 0.4.16
-Please run the unit tests and lint before submitting changes. Do not commit generated files from `app/build`.
+- Improved anti-burn-in clear-screen state retention and restoration across videos and photo posts.
+- Confirmed compatibility with official TikTok 46.3.3.
+- Updated the launcher icon with a solid-color background.
-## License
+## 0.4.15
-[MIT](LICENSE). Dependency credits and licenses are listed in [Third-party notices](THIRD_PARTY_NOTICES.md).
+- Limited support to the official TikTok client and removed compatibility code for modified clients.
+- Removed grayscale mode and forced unmute settings that depended on third-party client bridges.
+- Improved loop disabling so playback enters TikTok's native paused state and shows the replay frame.
+- Fixed the need for two taps to replay a video and the feature becoming inactive after switching videos.
+- Stopped forcing progress-bar synchronization to reduce reliance on high-frequency callbacks.
-## Support
+## 0.4.14
-If Toki is useful to you, you can support development through [Ko-fi](https://ko-fi.com/meiyongai) or [Alipay](app/src/main/res/drawable-nodpi/alipay.jpg). These options are also available in Toki under **Support Toki**. Donations are optional.
+- Added default playback speeds: 1.0x, 1.25x, 1.5x, 1.75x, and 2.0x.
+- Applied the selected speed to each new video after its first render without overriding manual speed changes.
+- Adapted to the official TikTok 46.3.3 player interface.
-
-USDT · TRC20 / Tron
+## 0.4.13
-`TXoTeZLpbQdn4wZF51858bC3zCwS822HbB`
+- Switched to libxposed API 102 and fixed the TikTok scope.
+- Reworked the Material 3 settings UI, region selection, and media-directory selection.
+- Added comment-translation state retention and scrolling-list synchronization.
+- Added the two-finger long-press anti-burn-in clear-screen mode.
+- Fixed the need for two taps to replay a video after loop disabling.
+- Added the Root-based Restart TikTok action.
-Use the TRC20 (Tron) network only. Verify the address and network before sending.
+## 中文
-
+### 0.4.23
-## Disclaimer
+- 新增首页并设为默认页面,可查看 LSPosed 服务状态与已安装的 TikTok 版本。
+- 将基于 Root 的 TikTok 重启操作移至首页,并新增仅清除缓存的 Root 操作;账号、设置、草稿和应用数据均会保留。
+- 新增界面语言手动切换,可选择跟随系统、English 或中文,并可一键重置全部 Toki 配置。
+- 新增 GitHub、Telegram 与问题反馈直达入口,导航扩展为首页、常规、信息流和下载。
+- 新增独立的游戏相关入口页面净化选项。
-Toki is an independent project, not affiliated with or endorsed by TikTok or ByteDance. It modifies app behavior and is provided **as is**, without warranties of compatibility, reliability or account safety. Use it at your own risk and follow applicable laws and platform terms. Respect creators' rights; download or reuse content only with permission.
+### 0.4.22
+
+- 新增“总是显示视频进度条”,移除普通视频短于 30 秒时隐藏进度条的限制;与隐藏进度条同时开启时,以隐藏为准。
+- 新增作者位置显示,可在作者昵称旁显示对应国家/地区旗帜和地区代码。
+- 将作者头像与作者信息拆分为两个独立的页面净化选项。
+- 更新 TikTok 46.4.3 的直播入口、搜索入口、顶部与底部导航栏、音乐标题和视频评价问卷净化路径。
+- 新增商业合作与推广、创作工具与模板、影视与动漫、分享与创作者激励、活动伤害警告等分类型净化选项。
+- 将 Feed 数据、标识、锚点、问卷和警告 Hook 整理到独立模块,并清除反编译伪包名与失效的旧警告路径。
+
+### 0.4.21
+
+- 新增页面净化选项,可隐藏 TikTok 主视频页面的手机系统状态栏。
+- 状态栏功能仅作用于 TikTok 46.4.3 的 `MainActivity`,并在页面布局更新后保持生效。
+- Android 11 及以上使用 `WindowInsetsController`,Android 8–10 使用兼容性的系统 UI 标记。
+
+### 0.4.20
+
+- 将官方 TikTok 46.4.3 设为唯一的实现、测试与维护目标。
+- 其他 TikTok 版本不在支持范围内,不再提供针对版本的专门适配。
+- 移除 46.3.x 保存位置和评论翻译兼容代码。
+- 将 Hook 按功能拆分为相互隔离的代码类,同时保持单一模块 APK 和入口。
+- 将 TikTok 46.4.3 推荐页过滤扩展到信息流消息分发和缓存列表插入路径。
+- 保留播放量与点赞数范围输入的原始文本,过滤计算仍使用解析后的数值。
+- 增加联网时禁用离线冷缓存的选项。
+
+### 0.4.19
+
+- 适配官方 TikTok 46.4.3 的评论翻译控件与播放完成处理。
+- 通过拦截 TikTok 的 MediaStore 写入桥,修复 TikTok 46.3.2 与 46.4.3 的视频、图片和 GIF 自定义保存位置。
+- 移除 Android 系统目录选择器,改用内置的共享存储相对路径编辑框。
+- 扩展 TikTok 46.4.3 的热点话题与推广浮层净化兼容。
+- 已知问题:TikTok 46.4.3 的播放量与点赞数筛选仍不可靠,少数范围外视频可能漏过过滤。
+
+### 0.4.18
+
+- 重构 Material 3 设置页,按常规、信息流和下载分类组织功能。
+- 新增页面净化,可选择隐藏作者信息、文案、音乐、互动按钮、搜索入口、Tako、翻译控件和导航栏。
+- 新增屏蔽 AI 生成内容、热点话题条和内容评级提示。
+- 新增 GPS、系统语言和系统时区伪装,均可独立开关并跟随目标地区。
+- 新增跳过启动登录引导,仅关闭启动时可跳过的登录提示,不绕过登录或验证。
+- 优化播放量与点赞量筛选,支持输入完整数字及 `K/M/B` 数量后缀。
+- 移除失败的防烧屏功能;默认倍速的标准项统一显示为 `1.0x`。
+- 优化下载说明,明确所有视频优先使用无水印地址。
+
+### 0.4.17
+
+- 支持官方 TikTok 46.3.2 与 46.3.3。
+- 修复 TikTok 46.3.2 评论页翻译按钮无法执行翻译的问题。
+- 适配 TikTok 46.3.2 的防烧屏状态提示 Toast 入口。
+
+### 0.4.16
+
+- 改进防烧屏清屏模式在视频与图集中的状态保持和恢复,增强页面切换后的稳定性。
+- 明确适配官方 TikTok 46.3.3。
+- 更新启动器图标,使用纯色背景。
+
+### 0.4.15
+
+- 明确仅支持官方 TikTok,移除第三方修改客户端专用兼容代码。
+- 移除仅依赖第三方客户端桥接、在官方 TikTok 中无效的灰度模式和强制取消静音设置。
+- 优化禁止循环播放:播放结束后进入 TikTok 原生暂停状态,并显示重播首帧。
+- 修复播放结束后需要点击两次才能重新播放,以及切换视频后功能失效的问题。
+- 不再强制同步播放进度条,减少对 TikTok 高频进度回调的依赖。
+
+### 0.4.14
+
+- 新增默认播放速度:1.0x、1.25x、1.5x、1.75x 和 2.0x。
+- 每条新视频首次渲染后自动应用所选速度,不覆盖当前视频内手动选择的倍速。
+- 适配官方 TikTok 46.3.3 播放器接口。
+
+### 0.4.13
+
+- 使用 libxposed API 102,并固定 TikTok 作用域。
+- 重构 Material 3 设置界面、地区选择和媒体保存目录选择。
+- 增加评论翻译状态保持及滚动列表同步。
+- 增加双指长按防烧屏清屏模式。
+- 修复禁止循环播放后需要点击两次才能重新播放的问题。
+- 增加 Root 重启 TikTok 操作。
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 45b2387..d860839 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -17,7 +17,7 @@ android {
compileSdk = 37
defaultConfig {
- applicationId = "io.github.meiyongai.toki"
+ applicationId = "com.toki.lsposed"
minSdk = 28
targetSdk = 35
versionCode = 1
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6c4c354..a390e64 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,9 +1,9 @@
-
-
+
@@ -40,7 +40,7 @@
diff --git a/app/src/main/java/com/toki/lsposed/TokiApplication.kt b/app/src/main/java/com/toki/lsposed/TokiApplication.kt
index 3fe2572..d702bce 100644
--- a/app/src/main/java/com/toki/lsposed/TokiApplication.kt
+++ b/app/src/main/java/com/toki/lsposed/TokiApplication.kt
@@ -3,6 +3,7 @@ package com.toki.lsposed
import android.app.Application
import com.toki.lsposed.provider.ConfigClient
import com.toki.lsposed.util.LSPosedStatusHelper
+import com.toki.lsposed.ui.TikTokRootLogMonitor
/** 管理端进程入口,在界面创建前准备本地设置和框架发布通道。 */
class TokiApplication : Application() {
@@ -15,5 +16,7 @@ class TokiApplication : Application() {
super.onCreate()
ConfigClient.init(this)
LSPosedStatusHelper.init()
+ // Root 诊断在 Toki 管理端独立运行,不向 TikTok 注入监控线程。
+ TikTokRootLogMonitor.ensureStarted()
}
}
diff --git a/app/src/main/java/com/toki/lsposed/hook/AuthorLocationHook.kt b/app/src/main/java/com/toki/lsposed/hook/AuthorLocationHook.kt
index 8247d13..ee4b083 100644
--- a/app/src/main/java/com/toki/lsposed/hook/AuthorLocationHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/AuthorLocationHook.kt
@@ -45,7 +45,7 @@ object AuthorLocationHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主应用启动与前台初始化时同步。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主应用启动与前台初始化时同步。
*/
fun refreshConfig(context: Context) {
ConfigClient.init(context)
diff --git a/app/src/main/java/com/toki/lsposed/hook/AutoScrollHook.kt b/app/src/main/java/com/toki/lsposed/hook/AutoScrollHook.kt
index 44dc2c5..244702b 100644
--- a/app/src/main/java/com/toki/lsposed/hook/AutoScrollHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/AutoScrollHook.kt
@@ -65,7 +65,7 @@ object AutoScrollHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主冷启动完成时分发。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主冷启动完成时分发。
*/
fun refreshConfig(context: Context) {
// 配置由 ConfigClient 统一管理并缓存
diff --git a/app/src/main/java/com/toki/lsposed/hook/CommentCopyHook.kt b/app/src/main/java/com/toki/lsposed/hook/CommentCopyHook.kt
index 54d0ad2..f3d2f2d 100644
--- a/app/src/main/java/com/toki/lsposed/hook/CommentCopyHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/CommentCopyHook.kt
@@ -17,7 +17,7 @@ object CommentCopyHook {
* 初始化共享配置;每次复制直接读取最新快照,不另存功能开关。
* @param context 已附加的宿主上下文。
* @return Unit。
- * Callers: TokiModule.hookApplication。
+ * Callers: TokiModule.hookApplicationIfNeeded。
*/
fun refreshConfig(context: Context) = ConfigClient.init(context)
diff --git a/app/src/main/java/com/toki/lsposed/hook/CommentTranslateHook.kt b/app/src/main/java/com/toki/lsposed/hook/CommentTranslateHook.kt
index 923fd86..65c8dad 100644
--- a/app/src/main/java/com/toki/lsposed/hook/CommentTranslateHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/CommentTranslateHook.kt
@@ -30,7 +30,7 @@ import java.lang.reflect.Method
*
* Callers:
* - `com.toki.lsposed.hook.TokiModule.onPackageLoaded`: 宿主目标包加载就绪后初始化挂载。
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 目标应用初始化时同步开关配置。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 目标应用初始化时同步开关配置。
*/
object CommentTranslateHook {
@@ -58,7 +58,7 @@ object CommentTranslateHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 应用启动阶段同步配置。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 应用启动阶段同步配置。
*/
fun refreshConfig(context: Context) {
isEnabled = ConfigClient.getBoolean(context, KEY_COMMENT_TRANSLATE_ENABLED)
diff --git a/app/src/main/java/com/toki/lsposed/hook/DownloadHook.kt b/app/src/main/java/com/toki/lsposed/hook/DownloadHook.kt
index 77709d5..c0868f8 100644
--- a/app/src/main/java/com/toki/lsposed/hook/DownloadHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/DownloadHook.kt
@@ -115,7 +115,7 @@ object DownloadHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主冷启动完成时分发。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主冷启动完成时分发。
*/
fun refreshConfig(context: Context) {
// 配置由 ConfigClient 统一管理并缓存
diff --git a/app/src/main/java/com/toki/lsposed/hook/FeedFilterHook.kt b/app/src/main/java/com/toki/lsposed/hook/FeedFilterHook.kt
index fd87917..4661dea 100644
--- a/app/src/main/java/com/toki/lsposed/hook/FeedFilterHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/FeedFilterHook.kt
@@ -117,7 +117,7 @@ object FeedFilterHook {
* 确保配置中心已初始化。
* @param context 已附加的宿主上下文。
* @return Unit。
- * Callers: TokiModule.hookApplication。
+ * Callers: TokiModule.hookApplicationIfNeeded。
*/
fun refreshConfig(context: Context) = ConfigClient.init(context)
diff --git a/app/src/main/java/com/toki/lsposed/hook/GpsHook.kt b/app/src/main/java/com/toki/lsposed/hook/GpsHook.kt
index 19ca3a7..00c7bbc 100644
--- a/app/src/main/java/com/toki/lsposed/hook/GpsHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/GpsHook.kt
@@ -42,7 +42,7 @@ object GpsHook {
* @param context 宿主目标应用的上下文对象。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主启动时触发配置加载。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主启动时触发配置加载。
*/
fun refreshConfig(context: Context) {
isEnabled = ConfigClient.getBoolean(context, "gps_spoof_enabled")
diff --git a/app/src/main/java/com/toki/lsposed/hook/HookRuntime.kt b/app/src/main/java/com/toki/lsposed/hook/HookRuntime.kt
index ccfbeda..797377e 100644
--- a/app/src/main/java/com/toki/lsposed/hook/HookRuntime.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/HookRuntime.kt
@@ -34,7 +34,7 @@ internal object HookRuntime {
* 注册仅响应管理端查询的诊断接收器,不创建定时发布任务。
* @param value 已附加的宿主上下文。
* @return Unit。
- * Callers: TokiModule.hookApplication。
+ * Callers: TokiModule.hookApplicationIfNeeded。
*/
@Synchronized fun attach(value: Context) {
if (receiver != null || process != value.packageName) return
@@ -163,7 +163,7 @@ internal object HookRuntime {
* @param feature 已注册的功能标识。
* @param configure 初始化或热更新操作。
* @return Unit。
- * Callers: TokiModule.refreshConfiguration。
+ * Callers: TokiModule.refreshEnabledConfiguration。
*/
fun configure(feature: String, configure: () -> Unit) {
if (synchronized(this) { handles[feature].isNullOrEmpty() }) return
diff --git a/app/src/main/java/com/toki/lsposed/hook/HostScanController.kt b/app/src/main/java/com/toki/lsposed/hook/HostScanController.kt
index cef8b18..edabd89 100644
--- a/app/src/main/java/com/toki/lsposed/hook/HostScanController.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/HostScanController.kt
@@ -20,7 +20,7 @@ internal class HostScanController private constructor(private val app: Applicati
* 在宿主主进程订阅活动生命周期;进程级控制器独立调度退出,窗口随前台活动释放。
* @param app 当前宿主Application。
* @return Unit。
- * Callers: TokiModule.hookApplication。
+ * Callers: TokiModule.hookApplicationIfNeeded。
*/
fun attach(app: Application) {
if (controller != null) return
@@ -39,6 +39,8 @@ internal class HostScanController private constructor(private val app: Applicati
private val main = Handler(Looper.getMainLooper())
private var foreground = WeakReference(null)
private var dialog: HostScanDialog? = null
+ /** 扫描 UI 创建失败时只禁用 UI,不得影响宿主扫描及业务 Hook。 */
+ private var dialogCreationFailed = false
private val refresh = object : Runnable {
/** 更新可见窗口;无前台活动时停止刷新。@return Unit。Callers: 主线程Handler。 */
override fun run() {
@@ -58,10 +60,27 @@ internal class HostScanController private constructor(private val app: Applicati
scheduleClose()
val state = session.status
if (state.phase == HostScanPhase.IDLE) { dismissDialog(); return }
- if (dialog == null) {
- dialog = HostScanDialog(activity) { session.dismissFailure(); dismissDialog() }.also { it.show() }
+ if (dialog == null && !dialogCreationFailed) {
+ runCatching {
+ HostScanDialog(activity) { session.dismissFailure(); dismissDialog() }.also { it.show() }
+ }.onSuccess { created ->
+ dialog = created
+ HookRuntime.event("TokiHostScan", "扫描窗口已创建 pid=${Process.myPid()}")
+ }.onFailure { error ->
+ // 扫描窗口是诊断/适配 UI,不是宿主功能依赖。任何 UI/资源异常都必须被吞掉,
+ // 否则异常发生在主线程会直接终止 TikTok。后台 DEX 扫描继续运行。
+ dialogCreationFailed = true
+ Log.e("TokiHostScan", "扫描窗口创建失败,继续后台扫描,不影响 TikTok", error)
+ HookRuntime.failure("HostScanDialog", error, "扫描窗口不可用,已降级为后台扫描")
+ }
+ }
+
+ runCatching { dialog?.render(state) }.onFailure { error ->
+ dialogCreationFailed = true
+ Log.e("TokiHostScan", "扫描窗口刷新失败,继续后台扫描,不影响 TikTok", error)
+ HookRuntime.failure("HostScanDialog", error, "扫描窗口刷新失败,已降级为后台扫描")
+ dismissDialog()
}
- dialog!!.render(state)
}
/**
@@ -105,6 +124,7 @@ internal class HostScanController private constructor(private val app: Applicati
/** 活动进入前台后恢复窗口。@param activity 宿主活动。@return Unit。Callers: Android Application。 */
override fun onActivityResumed(activity: Activity) {
dismissDialog()
+ dialogCreationFailed = false
foreground = WeakReference(activity)
main.post(refresh)
}
diff --git a/app/src/main/java/com/toki/lsposed/hook/HostScanDialog.kt b/app/src/main/java/com/toki/lsposed/hook/HostScanDialog.kt
index dfc2985..404278a 100644
--- a/app/src/main/java/com/toki/lsposed/hook/HostScanDialog.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/HostScanDialog.kt
@@ -1,8 +1,6 @@
package com.toki.lsposed.hook
import android.app.Activity
-import android.content.ContextWrapper
-import android.content.res.Resources
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.Window
@@ -46,7 +44,7 @@ import com.toki.lsposed.ui.tokiColorScheme
* Callers: HostScanController.render。
*/
internal class HostScanDialog(activity: Activity, onFailureDismiss: () -> Unit) :
- ComponentDialog(ScanContext(activity), android.R.style.Theme_Material_Light_Dialog_NoActionBar) {
+ ComponentDialog(activity, android.R.style.Theme_Material_Light_Dialog_NoActionBar) {
private var status by mutableStateOf(HostScanStatus())
private val composeView = ComposeView(context).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -93,27 +91,6 @@ internal class HostScanDialog(activity: Activity, onFailureDismiss: () -> Unit)
fun render(state: HostScanStatus) { status = state }
}
-/**
- * 保留宿主活动的窗口服务,隔离 Compose 读取的模块资源和主题。
- * @param activity 宿主前台活动。
- * Callers: HostScanDialog 构造函数。
- */
-private class ScanContext(activity: Activity) : ContextWrapper(activity) {
- private val moduleContext = activity.createPackageContext("io.github.meiyongai.toki", 0)
- .createConfigurationContext(activity.resources.configuration)
- private val moduleTheme = moduleContext.resources.newTheme().apply {
- applyStyle(android.R.style.Theme_Material_Light_Dialog_NoActionBar, true)
- }
- /** 提供模块资源。@return Toki 资源集合。Callers: Compose、ComponentDialog。 */
- override fun getResources(): Resources = moduleContext.resources
- /** 提供模块资产。@return Toki 资产管理器。Callers: Android 字体与资源加载。 */
- override fun getAssets(): android.content.res.AssetManager = moduleContext.assets
- /** 提供与模块资源一致的主题。@return 窗口主题。Callers: ComponentDialog。 */
- override fun getTheme(): Resources.Theme = moduleTheme
- /** 解析模块 Compose 类型。@return 模块加载器。Callers: Android 视图系统。 */
- override fun getClassLoader(): ClassLoader = checkNotNull(HostScanDialog::class.java.classLoader)
-}
-
/**
* 渲染紧凑 Material 3 窗口;说明和方法区域内部滚动,进度与状态固定在底部。
* @param state 当前阶段和工作量。
diff --git a/app/src/main/java/com/toki/lsposed/hook/ImmersiveFullScreenHook.kt b/app/src/main/java/com/toki/lsposed/hook/ImmersiveFullScreenHook.kt
index 6116b14..f0e222a 100644
--- a/app/src/main/java/com/toki/lsposed/hook/ImmersiveFullScreenHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/ImmersiveFullScreenHook.kt
@@ -92,7 +92,7 @@ object ImmersiveFullScreenHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主冷启动完成时分发。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主冷启动完成时分发。
*/
fun refreshConfig(context: Context) {
// 配置由 ConfigClient 统一管理并缓存
diff --git a/app/src/main/java/com/toki/lsposed/hook/LocaleHook.kt b/app/src/main/java/com/toki/lsposed/hook/LocaleHook.kt
index 5d51d66..12cfd61 100644
--- a/app/src/main/java/com/toki/lsposed/hook/LocaleHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/LocaleHook.kt
@@ -29,7 +29,7 @@ import java.util.Locale
*
* Callers:
* - `com.toki.lsposed.hook.TokiModule.onPackageLoaded`: 宿主目标包加载就绪后初始化拦截器。
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主应用启动时刷新并应用配置。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主应用启动时刷新并应用配置。
*/
object LocaleHook {
@@ -88,7 +88,7 @@ object LocaleHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主启动时触发配置加载。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主启动时触发配置加载。
*/
fun refreshConfig(context: Context) {
isEnabled = ConfigClient.getBoolean(context, "language_spoof_enabled")
@@ -126,7 +126,7 @@ object LocaleHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主 Application 创建时调用。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主 Application 创建时调用。
*/
fun applyToApplication(app: Application) {
if (!isEnabled) {
diff --git a/app/src/main/java/com/toki/lsposed/hook/MusicUnlockHook.kt b/app/src/main/java/com/toki/lsposed/hook/MusicUnlockHook.kt
index a843a40..4ce3ce5 100644
--- a/app/src/main/java/com/toki/lsposed/hook/MusicUnlockHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/MusicUnlockHook.kt
@@ -80,7 +80,7 @@ object MusicUnlockHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主冷启动完成时分发。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主冷启动完成时分发。
*/
fun refreshConfig(context: Context) {
// 配置由 ConfigClient 统一管理并缓存
diff --git a/app/src/main/java/com/toki/lsposed/hook/PlaybackSpeedHook.kt b/app/src/main/java/com/toki/lsposed/hook/PlaybackSpeedHook.kt
index 724690f..4364969 100644
--- a/app/src/main/java/com/toki/lsposed/hook/PlaybackSpeedHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/PlaybackSpeedHook.kt
@@ -132,7 +132,7 @@ object PlaybackSpeedHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主进程初始化时同步。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主进程初始化时同步。
*/
fun refreshConfig(context: Context) {
if (speedMemory == null) speedMemory = PlaybackSpeedMemory(
diff --git a/app/src/main/java/com/toki/lsposed/hook/SimHook.kt b/app/src/main/java/com/toki/lsposed/hook/SimHook.kt
index 288f390..78a77c7 100644
--- a/app/src/main/java/com/toki/lsposed/hook/SimHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/SimHook.kt
@@ -90,7 +90,7 @@ object SimHook {
* @param context 宿主目标应用的上下文对象。
*
* Callers:
- * - `com.toki.lsposed.hook.SimHook.hookApplication`: 宿主 Application 创建时触发同步。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主 Application 创建时触发同步。
*/
fun refreshConfig(context: Context) {
isEnabled = ConfigClient.getBoolean(context, "sim_spoof_enabled")
diff --git a/app/src/main/java/com/toki/lsposed/hook/StatusBarHook.kt b/app/src/main/java/com/toki/lsposed/hook/StatusBarHook.kt
index 77825d6..91487ec 100644
--- a/app/src/main/java/com/toki/lsposed/hook/StatusBarHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/StatusBarHook.kt
@@ -485,7 +485,7 @@ object StatusBarHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主冷启动完成时分发。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主冷启动完成时分发。
*/
fun refreshConfig(context: Context) {
// 配置由 ConfigClient 统一管理与跨进程分发
diff --git a/app/src/main/java/com/toki/lsposed/hook/TimeZoneHook.kt b/app/src/main/java/com/toki/lsposed/hook/TimeZoneHook.kt
index 32461f0..b9e27d0 100644
--- a/app/src/main/java/com/toki/lsposed/hook/TimeZoneHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/TimeZoneHook.kt
@@ -49,7 +49,7 @@ object TimeZoneHook {
* @param context 宿主目标应用的上下文对象。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主启动时触发配置加载。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主启动时触发配置加载。
*/
fun refreshConfig(context: Context) {
isEnabled = ConfigClient.getBoolean(context, "timezone_spoof_enabled")
@@ -78,7 +78,7 @@ object TimeZoneHook {
* @param app 宿主 Application 实例。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主 Application 创建时注入生效。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主 Application 创建时注入生效。
*/
fun applyToApplication(app: Application) {
if (!isEnabled) {
diff --git a/app/src/main/java/com/toki/lsposed/hook/TokiModule.kt b/app/src/main/java/com/toki/lsposed/hook/TokiModule.kt
index 43672cf..a6b7cf1 100644
--- a/app/src/main/java/com/toki/lsposed/hook/TokiModule.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/TokiModule.kt
@@ -1,9 +1,10 @@
package com.toki.lsposed.hook
import android.app.Application
-import android.app.Instrumentation
import android.util.Log
import com.toki.lsposed.provider.ConfigClient
+import com.toki.lsposed.provider.ConfigSchema
+import com.toki.lsposed.provider.TikTokCrashDiagnostics
import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
@@ -47,6 +48,7 @@ class TokiModule : XposedModule() {
override fun onModuleLoaded(param: ModuleLoadedParam) {
HookRuntime.start(param.processName) { tag, message -> log(Log.INFO, tag, message) }
processName = param.processName
+ TikTokCrashDiagnostics.install(this, param.processName)
configurationAvailableAtLoad = ConfigClient.initHost({ getRemotePreferences(ConfigClient.FRAMEWORK_GROUP) }) {
val error = ConfigClient.syncError
if (error != null) HookRuntime.failure("ConfigClient", error, "配置未就绪,功能暂停")
@@ -57,52 +59,97 @@ class TokiModule : XposedModule() {
}
/**
- * 在宿主 onCreate 前应用已验证配置;诊断和方法查找独立于配置连接状态。
+ * 仅在确实存在已开启功能时监听 Application 创建。
*
- * @return Unit。
- *
- * Callers:
- * - `com.toki.lsposed.hook.TokiModule.onPackageLoaded`: 目标包加载时触发。
+ * 所有功能关闭时不注册任何宿主 Application Hook,从源头消除模块对 TikTok
+ * 启动早期 Instrumentation 调用链的改动。
*/
- private fun hookApplication() {
- val onCreateMethod = Instrumentation::class.java.getMethod("callApplicationOnCreate", Application::class.java)
- hook(onCreateMethod).setExceptionMode(XposedInterface.ExceptionMode.PASSTHROUGH).intercept { chain ->
- val app = chain.args[0] as Application
- HookRuntime.attach(app)
- if (processName == app.packageName) HostScanController.attach(app)
- if (configurationAvailableAtLoad && ConfigClient.isReady) {
- refreshConfiguration(app)
- }
- chain.proceed()
+ private fun hookApplicationIfNeeded() {
+ if (!needsApplicationLifecycle()) return
+
+ val onCreateMethod = runCatching {
+ Class.forName(
+ "android.app.Instrumentation",
+ false,
+ Application::class.java.classLoader
+ ).getMethod("callApplicationOnCreate", Application::class.java)
+ }.getOrElse { error ->
+ HookRuntime.failure("ApplicationLifecycle", error, "Application Hook 不可用,宿主不被阻断")
+ return
}
- log(Log.INFO, TAG, "Application 配置应用与独立诊断入口已注册")
+
+ hook(onCreateMethod)
+ .setExceptionMode(XposedInterface.ExceptionMode.PASSTHROUGH)
+ .intercept { chain ->
+ // 诊断/扫描 UI 均不得成为宿主启动失败源。
+ runCatching {
+ val app = chain.args[0] as? Application ?: return@runCatching
+ HookRuntime.attach(app)
+ if (processName == app.packageName) HostScanController.attach(app)
+ refreshEnabledConfiguration(app)
+ }.onFailure { error ->
+ HookRuntime.failure(
+ "ApplicationLifecycle",
+ error,
+ "宿主启动辅助逻辑失败,已放行原调用"
+ )
+ }
+ chain.proceed()
+ }
+ HookRuntime.event("TokiModule", "仅因存在已开启功能才注册 Application 生命周期 Hook")
}
/**
- * 逐功能应用完整配置,各功能错误明确报告且不影响其它功能或宿主启动。
- * @param app 宿主应用实例。
- * @return Unit。
- * Callers: hookApplication;运行期更新由各功能的配置监听负责。
+ * 判断是否有功能需要 Application 上下文或宿主扫描窗口。
*/
- private fun refreshConfiguration(app: Application) {
- HookRuntime.configure("SimHook") { SimHook.refreshConfig(app) }
- HookRuntime.configure("LocaleHook") { LocaleHook.refreshConfig(app); LocaleHook.applyToApplication(app) }
- HookRuntime.configure("TimeZoneHook") { TimeZoneHook.refreshConfig(app); TimeZoneHook.applyToApplication(app) }
- HookRuntime.configure("GpsHook") { GpsHook.refreshConfig(app) }
- HookRuntime.configure("PlaybackSpeedHook") { PlaybackSpeedHook.refreshConfig(app) }
- HookRuntime.configure("CommentTranslateHook") { CommentTranslateHook.refreshConfig(app) }
- HookRuntime.configure("VideoTranslateHook") { VideoTranslateHook.refreshConfig(app) }
- HookRuntime.configure("CommentCopyHook") { CommentCopyHook.refreshConfig(app) }
- HookRuntime.configure("AuthorLocationHook") { AuthorLocationHook.refreshConfig(app) }
- HookRuntime.configure("ImmersiveFullScreenHook") { ImmersiveFullScreenHook.refreshConfig(app) }
- HookRuntime.configure("AutoScrollHook") { AutoScrollHook.refreshConfig(app) }
- HookRuntime.configure("FeedFilterHook") { FeedFilterHook.refreshConfig(app) }
- HookRuntime.configure("DownloadHook") { DownloadHook.refreshConfig(app) }
- HookRuntime.configure("MusicUnlockHook") { MusicUnlockHook.refreshConfig(app) }
- HookRuntime.configure("StatusBarHook") { StatusBarHook.refreshConfig(app) }
- HookRuntime.configure("VideoDurationAlertHook") { VideoDurationAlertHook.refreshConfig(app) }
+ private fun needsApplicationLifecycle(): Boolean =
+ APPLICATION_LIFECYCLE_FEATURES.any(::featureEnabled) || HOST_SYMBOL_FEATURES.any(::featureEnabled)
+
+ /**
+ * 判断指定功能是否至少有一个真实开关处于开启。
+ */
+ private fun featureEnabled(feature: String): Boolean =
+ ConfigSchema.featureSwitches[feature]?.any { key -> ConfigClient.getBoolean(key) } == true
+
+ /**
+ * 应用配置仅覆盖已注册且已启用的功能;关闭的功能不会触发初始化读取。
+ */
+ private fun refreshEnabledConfiguration(app: Application) {
+ HookRuntime.configure("SimHook") { if (featureEnabled("SimHook")) SimHook.refreshConfig(app) }
+ HookRuntime.configure("LocaleHook") { if (featureEnabled("LocaleHook")) { LocaleHook.refreshConfig(app); LocaleHook.applyToApplication(app) } }
+ HookRuntime.configure("TimeZoneHook") { if (featureEnabled("TimeZoneHook")) { TimeZoneHook.refreshConfig(app); TimeZoneHook.applyToApplication(app) } }
+ HookRuntime.configure("GpsHook") { if (featureEnabled("GpsHook")) GpsHook.refreshConfig(app) }
+ HookRuntime.configure("PlaybackSpeedHook") { if (featureEnabled("PlaybackSpeedHook")) PlaybackSpeedHook.refreshConfig(app) }
+ HookRuntime.configure("CommentTranslateHook") { if (featureEnabled("CommentTranslateHook")) CommentTranslateHook.refreshConfig(app) }
+ HookRuntime.configure("VideoTranslateHook") { if (featureEnabled("VideoTranslateHook")) VideoTranslateHook.refreshConfig(app) }
+ HookRuntime.configure("CommentCopyHook") { if (featureEnabled("CommentCopyHook")) CommentCopyHook.refreshConfig(app) }
+ HookRuntime.configure("AuthorLocationHook") { if (featureEnabled("AuthorLocationHook")) AuthorLocationHook.refreshConfig(app) }
+ HookRuntime.configure("ImmersiveFullScreenHook") { if (featureEnabled("ImmersiveFullScreenHook")) ImmersiveFullScreenHook.refreshConfig(app) }
+ HookRuntime.configure("AutoScrollHook") { if (featureEnabled("AutoScrollHook")) AutoScrollHook.refreshConfig(app) }
+ HookRuntime.configure("FeedFilterHook") { if (featureEnabled("FeedFilterHook")) FeedFilterHook.refreshConfig(app) }
+ HookRuntime.configure("DownloadHook") { if (featureEnabled("DownloadHook")) DownloadHook.refreshConfig(app) }
+ HookRuntime.configure("MusicUnlockHook") { if (featureEnabled("MusicUnlockHook")) MusicUnlockHook.refreshConfig(app) }
+ HookRuntime.configure("StatusBarHook") { if (featureEnabled("StatusBarHook")) StatusBarHook.refreshConfig(app) }
+ HookRuntime.configure("VideoDurationAlertHook") { if (featureEnabled("VideoDurationAlertHook")) VideoDurationAlertHook.refreshConfig(app) }
}
+ /**
+ * 有 Application 上下文需求的功能。其它功能通过 ConfigClient 快照实时读取。
+ */
+ private val APPLICATION_LIFECYCLE_FEATURES = setOf(
+ "SimHook", "LocaleHook", "TimeZoneHook", "GpsHook", "PlaybackSpeedHook"
+ )
+
+ /**
+ * 会访问 HostSymbols 的功能。没有这些功能开启时绝不触发 DEX 扫描。
+ */
+ private val HOST_SYMBOL_FEATURES = setOf(
+ "PlaybackSpeedHook", "CommentTranslateHook", "VideoTranslateHook",
+ "CommentCopyHook", "AuthorLocationHook", "ProgressBarHook",
+ "AutoCleanModeHook", "ImmersiveFullScreenHook", "AutoScrollHook",
+ "FeedFilterHook", "MusicUnlockHook"
+ )
+
/**
* 目标应用包完成加载时的生命周期回调。
*
@@ -125,22 +172,16 @@ class TokiModule : XposedModule() {
"目标应用包已加载 -> ${param.packageName} (isFirstPackage=${param.isFirstPackage})"
)
- // 优先注册宿主应用 Application 生命周期监听,确保跨进程配置中枢即刻就绪
- hookApplication()
-
if (!configurationAvailableAtLoad) return
- // 注册 SIM 卡与国家代码伪装 Hook
- HookRuntime.install("SimHook") { SimHook.init(this) }
-
- // 注册系统语言与 Locale 伪装 Hook
- HookRuntime.install("LocaleHook") { LocaleHook.init(this) }
+ // 只有明确开启的系统级功能才修改 Android/Java 框架方法。
+ installIfEnabled("SimHook") { SimHook.init(this) }
+ installIfEnabled("LocaleHook") { LocaleHook.init(this) }
+ installIfEnabled("TimeZoneHook") { TimeZoneHook.init(this) }
+ installIfEnabled("GpsHook") { GpsHook.init(this) }
- // 注册系统时区伪装 Hook
- HookRuntime.install("TimeZoneHook") { TimeZoneHook.init(this) }
-
- // 注册系统 GPS 定位伪装 Hook
- HookRuntime.install("GpsHook") { GpsHook.init(this) }
+ // Application Hook 也只在必要时注册;全部关闭时目标进程保持零业务注入。
+ hookApplicationIfNeeded()
}
@@ -152,46 +193,61 @@ class TokiModule : XposedModule() {
*/
override fun onPackageReady(param: PackageReadyParam) {
if (param.packageName !in SUPPORTED_PACKAGES || !param.isFirstPackage) return
- if (!HostSymbols.initialize(param.applicationInfo, processName == param.packageName)) return
if (!configurationAvailableAtLoad) return
- HookRuntime.install("FeedFilterHook") { FeedFilterHook.init(this, param.classLoader) }
+
+ // 没有需要符号解析的功能时,连 DEX 扫描都不要触发。
+ if (HOST_SYMBOL_FEATURES.any(::featureEnabled) &&
+ !HostSymbols.initialize(param.applicationInfo, processName == param.packageName)
+ ) return
+ installIfEnabled("FeedFilterHook") { FeedFilterHook.init(this, param.classLoader) }
// 注册固定播放倍速 Hook
- HookRuntime.install("PlaybackSpeedHook") { PlaybackSpeedHook.init(this, param.classLoader) }
+ installIfEnabled("PlaybackSpeedHook") { PlaybackSpeedHook.init(this, param.classLoader) }
// 注册评论区一键翻译 Hook
- HookRuntime.install("CommentTranslateHook") { CommentTranslateHook.init(this, param.classLoader) }
+ installIfEnabled("CommentTranslateHook") { CommentTranslateHook.init(this, param.classLoader) }
// 注册视频正文描述原生翻译 Hook
- HookRuntime.install("VideoTranslateHook") { VideoTranslateHook.init(this, param.classLoader) }
+ installIfEnabled("VideoTranslateHook") { VideoTranslateHook.init(this, param.classLoader) }
// 注册评论复制仅复制正文 Hook
- HookRuntime.install("CommentCopyHook") { CommentCopyHook.init(this, param.classLoader) }
+ installIfEnabled("CommentCopyHook") { CommentCopyHook.init(this, param.classLoader) }
// 注册作者地理位置显示 Hook
- HookRuntime.install("AuthorLocationHook") { AuthorLocationHook.init(this, param.classLoader) }
+ installIfEnabled("AuthorLocationHook") { AuthorLocationHook.init(this, param.classLoader) }
// 注册视频进度条常显 Hook
- HookRuntime.install("ProgressBarHook") { ProgressBarHook.init(this, param.classLoader) }
+ installIfEnabled("ProgressBarHook") { ProgressBarHook.init(this, param.classLoader) }
// 注册首页 Feed 自动清屏 Hook
- HookRuntime.install("AutoCleanModeHook") { AutoCleanModeHook.init(this, param.classLoader) }
+ installIfEnabled("AutoCleanModeHook") { AutoCleanModeHook.init(this, param.classLoader) }
// 注册全屏沉浸播放与视口贯通 Hook
- HookRuntime.install("ImmersiveFullScreenHook") { ImmersiveFullScreenHook.init(this, param.classLoader) }
+ installIfEnabled("ImmersiveFullScreenHook") { ImmersiveFullScreenHook.init(this, param.classLoader) }
// 注册 For You 流自动滚动地区解锁 Hook
- HookRuntime.install("AutoScrollHook") { AutoScrollHook.init(this, param.classLoader) }
+ installIfEnabled("AutoScrollHook") { AutoScrollHook.init(this, param.classLoader) }
// 注册视频保存增强(下载解锁 / 无水印 / 自定义路径)Hook
- HookRuntime.install("DownloadHook") { DownloadHook.init(this, param.classLoader) }
+ installIfEnabled("DownloadHook") { DownloadHook.init(this, param.classLoader) }
// 注册音频限制解锁(音乐级限制 / 视频级静音)Hook
- HookRuntime.install("MusicUnlockHook") { MusicUnlockHook.init(this, param.classLoader) }
+ installIfEnabled("MusicUnlockHook") { MusicUnlockHook.init(this, param.classLoader) }
// 注册系统状态栏隐藏 Hook(播放页特征:视频画布可见即隐藏)
- HookRuntime.install("StatusBarHook") { StatusBarHook.init(this, param.classLoader) }
+ installIfEnabled("StatusBarHook") { StatusBarHook.init(this, param.classLoader) }
// 注册长视频播放时长 Toast 提示 Hook
- HookRuntime.install("VideoDurationAlertHook") { VideoDurationAlertHook.init(this, param.classLoader) }
+ installIfEnabled("VideoDurationAlertHook") { VideoDurationAlertHook.init(this, param.classLoader) }
+ }
+
+ /**
+ * 配置关闭等同于“不注册”。这比在拦截器内部再判断开关更安全,避免关闭功能仍修改宿主关键启动/框架调用链。
+ */
+ private fun installIfEnabled(feature: String, install: () -> Unit) {
+ if (featureEnabled(feature)) {
+ HookRuntime.install(feature, install)
+ } else {
+ HookRuntime.state(feature, "已关闭,未注册")
+ }
}
}
diff --git a/app/src/main/java/com/toki/lsposed/hook/VideoDurationAlertHook.kt b/app/src/main/java/com/toki/lsposed/hook/VideoDurationAlertHook.kt
index b780bf7..d88b022 100644
--- a/app/src/main/java/com/toki/lsposed/hook/VideoDurationAlertHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/VideoDurationAlertHook.kt
@@ -174,7 +174,7 @@ object VideoDurationAlertHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 宿主冷启动完成时分发。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 宿主冷启动完成时分发。
*/
fun refreshConfig(context: Context) {
appContext = context.applicationContext
diff --git a/app/src/main/java/com/toki/lsposed/hook/VideoTranslateHook.kt b/app/src/main/java/com/toki/lsposed/hook/VideoTranslateHook.kt
index 6e91fa0..b89d522 100644
--- a/app/src/main/java/com/toki/lsposed/hook/VideoTranslateHook.kt
+++ b/app/src/main/java/com/toki/lsposed/hook/VideoTranslateHook.kt
@@ -31,7 +31,7 @@ import java.lang.reflect.Method
*
* Callers:
* - `com.toki.lsposed.hook.TokiModule.onPackageLoaded`: 宿主目标包加载就绪后初始化挂载。
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 目标应用初始化时同步开关配置。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 目标应用初始化时同步开关配置。
*/
object VideoTranslateHook {
@@ -66,7 +66,7 @@ object VideoTranslateHook {
* Unit: 无返回值。
*
* Callers:
- * - `com.toki.lsposed.hook.TokiModule.hookApplication`: 应用启动阶段同步配置。
+ * - `com.toki.lsposed.hook.TokiModule.hookApplicationIfNeeded`: 应用启动阶段同步配置。
*/
fun refreshConfig(context: Context) {
isEnabled = ConfigClient.getBoolean(context, KEY_VIDEO_TRANSLATE_ENABLED)
diff --git a/app/src/main/java/com/toki/lsposed/provider/ConfigClient.kt b/app/src/main/java/com/toki/lsposed/provider/ConfigClient.kt
index 56a63d2..2e7f43e 100644
--- a/app/src/main/java/com/toki/lsposed/provider/ConfigClient.kt
+++ b/app/src/main/java/com/toki/lsposed/provider/ConfigClient.kt
@@ -37,7 +37,7 @@ object ConfigClient {
*/
@Synchronized fun init(context: Context) {
if (initialized) return
- check(!host && context.packageName == "io.github.meiyongai.toki") { "宿主必须通过框架初始化配置" }
+ check(!host && context.packageName == "com.toki.lsposed") { "宿主必须通过框架初始化配置" }
store.attachLocal(context.getSharedPreferences(ConfigStore.LOCAL_NAME, Context.MODE_PRIVATE))
initialized = true
}
diff --git a/app/src/main/java/com/toki/lsposed/provider/HookDiagnostics.kt b/app/src/main/java/com/toki/lsposed/provider/HookDiagnostics.kt
index 010fea9..42824a8 100644
--- a/app/src/main/java/com/toki/lsposed/provider/HookDiagnostics.kt
+++ b/app/src/main/java/com/toki/lsposed/provider/HookDiagnostics.kt
@@ -11,8 +11,8 @@ import kotlin.coroutines.resume
/** 仅由管理端主动查询的诊断协议;不启动宿主,也不要求宿主唤起管理端服务。 */
internal object HookDiagnostics {
- const val ACTION = "io.github.meiyongai.toki.REQUEST_DIAGNOSTICS"
- const val PERMISSION = "io.github.meiyongai.toki.permission.READ_DIAGNOSTICS"
+ const val ACTION = "com.toki.lsposed.REQUEST_DIAGNOSTICS"
+ const val PERMISSION = "com.toki.lsposed.permission.READ_DIAGNOSTICS"
val hosts = setOf("com.zhiliaoapp.musically", "com.ss.android.ugc.trill")
/**
diff --git a/app/src/main/java/com/toki/lsposed/provider/TikTokCrashDiagnostics.kt b/app/src/main/java/com/toki/lsposed/provider/TikTokCrashDiagnostics.kt
new file mode 100644
index 0000000..11bd8e0
--- /dev/null
+++ b/app/src/main/java/com/toki/lsposed/provider/TikTokCrashDiagnostics.kt
@@ -0,0 +1,165 @@
+package com.toki.lsposed.provider
+
+import android.app.Application
+import android.content.ContentValues
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.util.Log
+import com.toki.lsposed.hook.HookRuntime
+import io.github.libxposed.api.XposedInterface
+import io.github.libxposed.api.XposedModule
+import java.io.File
+import java.io.StringWriter
+import java.io.PrintWriter
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * TikTok 崩溃诊断,仅挂 RuntimeInit 的最终未捕获异常处理器,不修改 TikTok 业务方法。
+ * Android 10+ 通过 MediaStore 直接把完整 Java 崩溃栈写入公共 Download/Toki/Crash。
+ */
+internal object TikTokCrashDiagnostics {
+ private const val TAG = "TokiCrashDiagnostics"
+ private const val GLOBAL_PACKAGE = "com.zhiliaoapp.musically"
+ private const val TRILL_PACKAGE = "com.ss.android.ugc.trill"
+ private val installedProcesses = mutableSetOf()
+ private val writeGuard = AtomicBoolean(false)
+
+ /**
+ * 只在 TikTok 自身进程注册诊断 Hook;其它 LSPosed 作用域进程直接忽略。
+ */
+ @Synchronized
+ fun install(module: XposedModule, processName: String) {
+ if (!isTikTokProcess(processName) || !installedProcesses.add(processName)) return
+
+ val method = runCatching {
+ Class.forName("com.android.internal.os.RuntimeInit\$KillApplicationHandler", false, null)
+ .getDeclaredMethod("uncaughtException", Thread::class.java, Throwable::class.java)
+ .apply { isAccessible = true }
+ }.getOrElse { error ->
+ HookRuntime.failure("CrashDiagnostics", error, "Runtime 崩溃处理器不可用,交由 Root 日志监听")
+ return
+ }
+
+ runCatching {
+ module.hook(method)
+ .setExceptionMode(XposedInterface.ExceptionMode.PASSTHROUGH)
+ .intercept { chain ->
+ try {
+ val throwable = chain.args.getOrNull(1) as? Throwable
+ if (throwable != null) writeCrashReport(processName, throwable)
+ } catch (error: Throwable) {
+ // 诊断失败不能阻断宿主原始崩溃处理链。
+ Log.e(TAG, "写入 TikTok 崩溃报告失败", error)
+ }
+ chain.proceed()
+ }
+ HookRuntime.state("CrashDiagnostics", "已挂载 Runtime 崩溃监听")
+ HookRuntime.event("TokiCrashDiagnostics", "已为 $processName 注册 Runtime 未捕获异常监听")
+ }.onFailure { error ->
+ HookRuntime.failure("CrashDiagnostics", error, "崩溃监听注册失败,交由 Root 日志监听")
+ }
+ }
+
+ fun isTikTokProcess(processName: String): Boolean =
+ processName == GLOBAL_PACKAGE || processName.startsWith("$GLOBAL_PACKAGE:") ||
+ processName == TRILL_PACKAGE || processName.startsWith("$TRILL_PACKAGE:")
+
+ private fun currentApplication(): Application? = runCatching {
+ Class.forName("android.app.ActivityThread", false, null)
+ .getDeclaredMethod("currentApplication")
+ .apply { isAccessible = true }
+ .invoke(null) as? Application
+ }.getOrNull()
+
+ private fun writeCrashReport(processName: String, throwable: Throwable) {
+ if (!writeGuard.compareAndSet(false, true)) return
+ try {
+ val app = currentApplication() ?: return
+ val now = System.currentTimeMillis()
+ val stamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date(now))
+ val fileName = "Toki_TikTok_Crash_${stamp}_${android.os.Process.myPid()}.txt"
+ val report = buildReport(app, processName, now, throwable)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ writeWithMediaStore(app, fileName, report)
+ } else {
+ writeLegacyDownload(fileName, report)
+ }
+ } finally {
+ writeGuard.set(false)
+ }
+ }
+
+ private fun buildReport(
+ app: Application,
+ processName: String,
+ time: Long,
+ throwable: Throwable
+ ): String = buildString {
+ appendLine("Toki TikTok Crash Report")
+ appendLine("time=$time")
+ appendLine("package=${app.packageName}")
+ appendLine("process=$processName")
+ appendLine("pid=${android.os.Process.myPid()}")
+ appendLine("android=${Build.VERSION.RELEASE} (api ${Build.VERSION.SDK_INT})")
+ runCatching {
+ val info = app.packageManager.getPackageInfo(app.packageName, 0)
+ appendLine("versionName=${info.versionName}")
+ @Suppress("DEPRECATION")
+ val versionCode = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
+ appendLine("versionCode=$versionCode")
+ }
+ appendLine("thread=${Thread.currentThread().name}")
+ appendLine()
+ appendLine("--- uncaught exception ---")
+ val stack = StringWriter()
+ PrintWriter(stack).use { writer -> throwable.printStackTrace(writer) }
+ append(stack.toString())
+ }
+
+ private fun writeWithMediaStore(app: Application, fileName: String, report: String) {
+ val values = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+ put(MediaStore.MediaColumns.MIME_TYPE, "text/plain")
+ put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/Toki/Crash")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ }
+ val uri = app.contentResolver.insert(
+ MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
+ values
+ ) ?: error("MediaStore insert returned null")
+
+ try {
+ app.contentResolver.openOutputStream(uri, "w").use { output ->
+ requireNotNull(output) { "无法打开崩溃报告输出流" }
+ output.write(report.toByteArray(Charsets.UTF_8))
+ output.flush()
+ }
+ ContentValues().apply {
+ put(MediaStore.MediaColumns.IS_PENDING, 0)
+ }.also { app.contentResolver.update(uri, it, null, null) }
+ } catch (error: Throwable) {
+ runCatching { app.contentResolver.delete(uri, null, null) }
+ throw error
+ }
+ Log.e(TAG, "TikTok 崩溃报告已写入 Download/Toki/Crash/$fileName")
+ }
+
+ @Suppress("DEPRECATION")
+ private fun writeLegacyDownload(fileName: String, report: String) {
+ val directory = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "Toki/Crash"
+ )
+ if (!directory.exists() && !directory.mkdirs()) {
+ error("无法创建 Download/Toki/Crash")
+ }
+ File(directory, fileName).writeText(report, Charsets.UTF_8)
+ }
+}
diff --git a/app/src/main/java/com/toki/lsposed/ui/LauncherIconController.kt b/app/src/main/java/com/toki/lsposed/ui/LauncherIconController.kt
index e59afd5..d0746b0 100644
--- a/app/src/main/java/com/toki/lsposed/ui/LauncherIconController.kt
+++ b/app/src/main/java/com/toki/lsposed/ui/LauncherIconController.kt
@@ -19,7 +19,7 @@ object LauncherIconController {
private const val TAG = "TokiLauncherIcon"
/** 桌面启动别名组件名(与 Manifest 中 activity-alias 一致) */
- private const val ALIAS_CLASS = "io.github.meiyongai.toki.LauncherAlias"
+ private const val ALIAS_CLASS = "com.toki.lsposed.LauncherAlias"
/**
* 查询桌面图标当前是否处于隐藏状态。
@@ -76,5 +76,5 @@ object LauncherIconController {
* - `com.toki.lsposed.ui.LauncherIconController.setIconHidden`: 状态切换。
*/
private fun aliasComponent(): ComponentName =
- ComponentName("io.github.meiyongai.toki", ALIAS_CLASS)
+ ComponentName("com.toki.lsposed", ALIAS_CLASS)
}
diff --git a/app/src/main/java/com/toki/lsposed/ui/TikTokRootLogMonitor.kt b/app/src/main/java/com/toki/lsposed/ui/TikTokRootLogMonitor.kt
new file mode 100644
index 0000000..97d16e0
--- /dev/null
+++ b/app/src/main/java/com/toki/lsposed/ui/TikTokRootLogMonitor.kt
@@ -0,0 +1,107 @@
+package com.toki.lsposed.ui
+
+import android.util.Log
+import java.io.IOException
+import java.util.concurrent.Executors
+
+/**
+ * Root 侧独立诊断监听。
+ *
+ * 监听 Android crash/events/system 日志中的 Java crash、native fatal signal 与 ANR 事件,
+ * 不向 TikTok 进程注入额外代码。Root shell 脱离 Toki 管理端后持续写入公共 Download。
+ */
+internal object TikTokRootLogMonitor {
+ private const val TAG = "TokiRootLogMonitor"
+ private const val LOG_FILE = "/sdcard/Download/Toki/TikTok_Runtime.log"
+ private const val PID_FILE = "/sdcard/Download/Toki/.tiktok_monitor.pid"
+ private val executor = Executors.newSingleThreadExecutor { runnable ->
+ Thread(runnable, "TokiTikTokLogMonitor").apply { isDaemon = true }
+ }
+
+ /** 异步尝试启动 Root 监听;无 Root 时安静失败,不影响 Toki 或 TikTok。 */
+ fun ensureStarted() {
+ executor.execute {
+ runCatching { start() }
+ .onFailure { Log.i(TAG, "Root TikTok 诊断监听未启动:${it.message}") }
+ }
+ }
+
+ /** 停止已有 Root 监听,供后续诊断控制使用。 */
+ fun stopAsync() {
+ executor.execute {
+ runCatching { runSu(stopCommand()) }
+ .onFailure { Log.w(TAG, "停止 Root TikTok 诊断监听失败", it) }
+ }
+ }
+
+ private fun start() {
+ runSu(startCommand())
+ }
+
+ private fun startCommand(): String {
+ val d = '$'
+ val script = """
+ mkdir -p '$LOG_DIR'
+ if [ -f '$PID_FILE' ]; then
+ old_pid=${d}(cat '$PID_FILE' 2>/dev/null)
+ if [ -n "${d}old_pid" ] && kill -0 "${d}old_pid" 2>/dev/null; then
+ exit 0
+ fi
+ rm -f '$PID_FILE'
+ fi
+ (
+ printf '\n=== Toki TikTok crash/ANR monitor started %s ===\n' "${d}(date '+%Y-%m-%d %H:%M:%S')" >> '$LOG_FILE'
+ capture=0
+ buffer='$LOG_FILE.capture.'${d}${d}
+ rm -f "${d}buffer"
+ trap 'rm -f "${d}buffer"' EXIT HUP INT TERM
+ logcat -v threadtime -b crash -b events -b system 2>/dev/null | while IFS= read -r line; do
+ case "${d}line" in
+ *"FATAL EXCEPTION"*|*"Fatal signal"*|*"SIGSEGV"*|*"SIGABRT"*|*"SIGBUS"*|*"am_crash"*|*"am_anr"*|*"ANR in"*|*"Application Not Responding"*|*"not responding"*)
+ : > "${d}buffer"
+ capture=80
+ ;;
+ esac
+ if [ "${d}capture" -gt 0 ]; then
+ printf '%s\n' "${d}line" >> "${d}buffer"
+ capture=${d}((capture - 1))
+ if [ "${d}capture" -eq 0 ]; then
+ if grep -Eq 'com\.zhiliaoapp\.musically|com\.ss\.android\.ugc\.trill' "${d}buffer" 2>/dev/null; then
+ printf '%s\n' "" >> '$LOG_FILE'
+ printf '=== TikTok crash/ANR trigger ===\n' >> '$LOG_FILE'
+ cat "${d}buffer" >> '$LOG_FILE'
+ fi
+ : > "${d}buffer"
+ fi
+ fi
+ done
+ ) &
+ echo ${d}! > '$PID_FILE'
+ """.trimIndent()
+ return "nohup sh -c ${shellQuote(script)} >/dev/null 2>&1 &"
+ }
+
+ private const val LOG_DIR = "/sdcard/Download/Toki"
+
+ private fun stopCommand(): String =
+ "if [ -f '$PID_FILE' ]; then p=\$(cat '$PID_FILE' 2>/dev/null); [ -n \"\$p\" ] && kill \"\$p\" 2>/dev/null; rm -f '$PID_FILE'; fi"
+
+ private fun runSu(command: String) {
+ val process = try {
+ ProcessBuilder("su", "-c", command)
+ .redirectErrorStream(true)
+ .start()
+ } catch (error: IOException) {
+ throw IllegalStateException("无法启动 su", error)
+ }
+ val output = process.inputStream.bufferedReader().use { it.readText() }
+ val code = process.waitFor()
+ if (code != 0) {
+ throw IllegalStateException("su exit=$code output=$output")
+ }
+ }
+
+ /** 单引号 Shell 字符串转义,脚本本身不包含任何用户输入。 */
+ private fun shellQuote(value: String): String =
+ "'" + value.replace("'", "'\"'\"'") + "'"
+}
diff --git a/app/src/test/java/com/toki/lsposed/provider/TikTokCrashDiagnosticsTest.kt b/app/src/test/java/com/toki/lsposed/provider/TikTokCrashDiagnosticsTest.kt
new file mode 100644
index 0000000..c5cd802
--- /dev/null
+++ b/app/src/test/java/com/toki/lsposed/provider/TikTokCrashDiagnosticsTest.kt
@@ -0,0 +1,21 @@
+package com.toki.lsposed.provider
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** 诊断监听只允许 TikTok 主进程及其子进程,避免把 Runtime 崩溃 Hook 扩散到其它进程。 */
+class TikTokCrashDiagnosticsTest {
+ @Test fun acceptsTikTokMainAndChildProcesses() {
+ assertTrue(TikTokCrashDiagnostics.isTikTokProcess("com.zhiliaoapp.musically"))
+ assertTrue(TikTokCrashDiagnostics.isTikTokProcess("com.zhiliaoapp.musically:push"))
+ assertTrue(TikTokCrashDiagnostics.isTikTokProcess("com.ss.android.ugc.trill"))
+ assertTrue(TikTokCrashDiagnostics.isTikTokProcess("com.ss.android.ugc.trill:tools"))
+ }
+
+ @Test fun rejectsLookalikeOrOtherPackages() {
+ assertFalse(TikTokCrashDiagnostics.isTikTokProcess("com.zhiliaoapp.musically.fake"))
+ assertFalse(TikTokCrashDiagnostics.isTikTokProcess("com.toki.lsposed"))
+ assertFalse(TikTokCrashDiagnostics.isTikTokProcess("android"))
+ }
+}
diff --git a/app/src/test/java/com/toki/lsposed/util/LocalizedResourceContextTest.kt b/app/src/test/java/com/toki/lsposed/util/LocalizedResourceContextTest.kt
index b298621..2039c45 100644
--- a/app/src/test/java/com/toki/lsposed/util/LocalizedResourceContextTest.kt
+++ b/app/src/test/java/com/toki/lsposed/util/LocalizedResourceContextTest.kt
@@ -55,7 +55,7 @@ class LocalizedResourceContextTest {
for (index in 0 until nodes.length) {
val element = nodes.item(index) as Element
val name = element.getAttribute("name")
- val resourceId = localized.resources.getIdentifier(name, "string", localized.packageName)
+ val resourceId = localized.resources.getIdentifier(name, "string", "com.toki.lsposed")
assertTrue("Unknown resource: $name", resourceId != 0)
val expected = decode(element.textContent)
assertEquals("${language.languageTag}/$name", expected, localized.getString(resourceId))
diff --git a/gradlew b/gradlew
old mode 100755
new mode 100644
diff --git a/gradlew.bat b/gradlew.bat
index 9d21a21..9b42019 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,94 +1,94 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-@rem SPDX-License-Identifier: Apache-2.0
-@rem
-
-@if "%DEBUG%"=="" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%"=="" set DIRNAME=.
-@rem This is normally unused
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Resolve any "." and ".." in APP_HOME to make it shorter.
-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if %ERRORLEVEL% equ 0 goto execute
-
-echo. 1>&2
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
-echo. 1>&2
-echo Please set the JAVA_HOME variable in your environment to match the 1>&2
-echo location of your Java installation. 1>&2
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo. 1>&2
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
-echo. 1>&2
-echo Please set the JAVA_HOME variable in your environment to match the 1>&2
-echo location of your Java installation. 1>&2
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega