diff --git a/TASKER_GUIDE.md b/TASKER_GUIDE.md new file mode 100644 index 0000000000..08f3942243 --- /dev/null +++ b/TASKER_GUIDE.md @@ -0,0 +1,233 @@ +# Tasker 自动化配置指南 + +本指南详细说明如何在 Tasker 中配置 Clash Meta for Android (CMFA) 的自动化控制。 + +## 📌 重要提示 + +**使用 BroadcastReceiver 方式可以实现完全后台控制,不会弹出任何界面!** + +## 前提条件 + +1. 已安装 CMFA(编译包含 ExternalControlReceiver 的版本) +2. 已安装 Tasker +3. 已授予 Tasker 必要的权限 +4. **首次使用前,必须在 CMFA 中手动启动一次 VPN 并授予权限** +5. **确认你的应用包名**(见下方说明) + +### 📦 如何确认应用包名 + +**非常重要**:不同的编译版本和配置会有不同的包名。请使用以下方法确认你的应用包名: + +**方法 1:通过 ADB(推荐)** +```bash +adb shell pm list packages | grep clash +``` + +**方法 2:通过应用信息** +1. 长按 CMFA 应用图标 +2. 点击"应用信息" +3. 查看应用详情中的"包名"字段 + +常见的包名: +- 自定义构建版本:`com.github.kr328.clash.tasker`(或其他自定义名称) +- Alpha 官方版本:`com.github.kr328.clash.alpha` +- Meta 官方版本:`com.github.metacubex.clash.meta` + +**在下面的配置中,请将 `YOUR_PACKAGE_NAME` 替换为你实际的包名!** + +⚠️ **重要:首次 VPN 权限授予** + +在使用 Tasker 自动化之前,必须: +1. 打开 CMFA 应用 +2. 手动启动一次代理(会弹出 VPN 权限请求) +3. 授予 VPN 权限并勾选"记住选择" +4. 停止代理 + +**之后的 Tasker 自动化才能正常工作!** + +## 方案一:BroadcastReceiver 方式(推荐) + +### 优势 +- ✅ **完全后台运行**,不会触发任何界面 +- ✅ 适用于**所有 ROM**(包括 Flyme、MIUI、ColorOS 等国产 ROM) +- ✅ 无需 Root 权限 +- ✅ 不受系统"后台启动限制"影响 + +### 步骤 1:创建启动 Clash 的 Task + +1. 打开 Tasker,点击底部 **"TASKS"** 标签 +2. 点击右下角 **"+"** 按钮,创建新任务 +3. 输入任务名称:`启动 Clash` +4. 点击 **"+"** 添加动作 +5. 选择 **System** → **Send Intent** +6. 填写以下参数: + + | 参数 | 值 | + |------|-----| + | **Action** | `com.github.metacubex.clash.meta.action.START_CLASH` | + | **Cat** | 留空 | + | **Mime Type** | 留空 | + | **Data** | 留空 | + | **Extra** | 留空 | + | **Package** | `YOUR_PACKAGE_NAME` ⚠️(替换为你的实际包名,例如 `com.github.kr328.clash.tasker`) | + | **Class** | 留空(重要!) | + | **Target** | **Broadcast Receiver**(非常重要!) | + +7. 点击 **返回** 保存 + +**示例**:如果你的包名是 `com.github.kr328.clash.tasker`,则 Package 字段应填写:`com.github.kr328.clash.tasker` + +### 步骤 2:创建停止 Clash 的 Task + +重复步骤 1,但修改以下内容: +- 任务名称:`停止 Clash` +- **Action**:`com.github.metacubex.clash.meta.action.STOP_CLASH` +- 其他参数保持不变 + +### 步骤 3:创建切换 Clash 的 Task(可选) + +如果你想要一个单键切换开关: +- 任务名称:`切换 Clash` +- **Action**:`com.github.metacubex.clash.meta.action.TOGGLE_CLASH` +- 其他参数保持不变 + +### 步骤 4:创建自动化 Profile + +#### 场景 1:连接家庭 Wi-Fi 时自动关闭 Clash + +1. 点击底部 **"PROFILES"** 标签 +2. 点击右下角 **"+"** 创建新 Profile +3. 选择 **State** → **Net** → **Wifi Connected** +4. 在 **SSID** 字段输入你的家庭 Wi-Fi 名称(例如:`My Home WiFi`) +5. 点击返回 +6. 在弹出的任务选择窗口中,选择 **`停止 Clash`** +7. 完成!当连接到指定 Wi-Fi 时,Clash 会自动停止 + +#### 场景 2:离开家庭 Wi-Fi 时自动启动 Clash + +1. 长按上面创建的 Profile +2. 点击 **"Add Exit Task"**(添加退出任务) +3. 选择 **`启动 Clash`** +4. 完成!当断开指定 Wi-Fi 时,Clash 会自动启动 + +#### 场景 3:充电时启动,拔电时停止 + +**充电时启动:** +1. 创建新 Profile:**State** → **Power** → **Power** +2. 选择 **Any**(任何充电方式) +3. 关联任务:**`启动 Clash`** + +**拔电时停止:** +1. 长按上面的 Profile +2. 点击 **"Add Exit Task"** +3. 选择 **`停止 Clash`** + +#### 场景 4:特定时间段自动控制 + +**晚上 11 点自动关闭:** +1. 创建新 Profile:**Time** → 设置时间为 `23:00` +2. 关联任务:**`停止 Clash`** + +**早上 7 点自动启动:** +1. 创建新 Profile:**Time** → 设置时间为 `07:00` +2. 关联任务:**`启动 Clash`** + +### 步骤 5:测试 + +1. 手动运行任务:在 TASKS 界面,点击任务名称旁的播放按钮 +2. 观察手机屏幕:**应该不会弹出任何界面** +3. 打开 CMFA 应用,检查服务状态是否改变 +4. 触发 Profile 条件(如连接/断开 Wi-Fi),验证自动化是否生效 + +## 方案二:Activity 方式(传统方式) + +**注意:** 此方式在 Flyme 等国产 ROM 上可能会短暂弹出界面,不推荐使用。 + +### 配置方法 + +与方案一基本相同,只需修改: +- **Target**:**Activity**(而非 Broadcast Receiver) +- **Class**:`com.github.kr328.clash.ExternalControlActivity` + +## 常见问题 + +### Q1: 为什么还是会弹出界面? + +**A:** 请确认以下几点: +1. 你编译的 APK 包含了 `ExternalControlReceiver` +2. Tasker 中 **Target** 设置为 **Broadcast Receiver**(不是 Activity) +3. **Class** 字段留空(非常重要!) + +### Q2: 提示"找不到组件"或"Intent 发送失败" + +**A:** 检查: +1. **Package** 是否正确:`com.github.metacubex.clash.meta` +2. **Action** 是否正确(区分大小写) +3. CMFA 是否已正确安装 +4. 是否使用了包含 BroadcastReceiver 的版本 + +### Q3: 自动化不生效 + +**A:** 排查步骤: +1. 在 Tasker 中手动运行任务,看是否能控制 Clash +2. 检查 Profile 的触发条件是否正确 +3. 确认 Tasker 有足够的权限(电池优化白名单、后台运行权限等) +4. 查看 Tasker 的日志(运行日志功能) + +### Q4: 首次启动 VPN 时还是会弹出权限请求 + +**A:** 这是正常的。Android 要求用户首次授予 VPN 权限时必须有用户交互。解决方法: +1. 首次手动在 CMFA 中启动一次,授予 VPN 权限 +2. 勾选"记住选择"或"不再提示" +3. 之后的自动化控制就不会再弹窗了 + +### Q5: 如何验证使用的是 BroadcastReceiver 方式? + +**A:** +1. 运行 Tasker 任务 +2. 如果屏幕**完全没有任何反应**(不闪屏、不弹窗),说明使用的是 BroadcastReceiver +3. 如果短暂看到 CMFA 界面,说明还是在使用 Activity 方式 + +## 高级技巧 + +### 结合其他条件 + +你可以在 Profile 中添加多个条件(AND 逻辑): + +**例如:工作日早上 8-18 点,且不在家庭 Wi-Fi 时,启动 Clash** + +1. 创建 Profile +2. 添加条件 1:**Time** → 08:00 to 18:00 +3. 点击左上角 **"+"** 添加条件 2:**Day** → 选择周一到周五 +4. 再添加条件 3:**State** → **Wifi Connected** → **Invert**(反选)→ 输入家庭 Wi-Fi SSID +5. 关联任务:**`启动 Clash`** + +### 创建桌面快捷方式 + +1. 长按任务 +2. 选择 **"Create Widget"** +3. 拖动到桌面 +4. 点击桌面图标即可一键控制 Clash + +## 对比:BroadcastReceiver vs Activity + +| 特性 | BroadcastReceiver | Activity | +|------|------------------|----------| +| 后台运行 | ✅ 完全后台 | ⚠️ 可能弹窗 | +| ROM 兼容性 | ✅ 所有 ROM | ⚠️ Flyme 等会前台化 | +| 实现复杂度 | 简单 | 简单 | +| 需要改源码 | ✅ 是(已完成) | ❌ 否(官方已支持) | +| 用户体验 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | + +## 总结 + +使用 **BroadcastReceiver 方式**,你可以在**任何 ROM**上实现**完全后台**的 Clash 自动化控制,不会有任何界面干扰。配置完成后,Clash 会根据你设定的条件(Wi-Fi、时间、充电状态等)自动启停,真正做到"无感知"自动化。 + +## 反馈 + +如果遇到任何问题,请检查: +1. Tasker 配置是否正确(特别是 Target 字段) +2. CMFA 版本是否包含 `ExternalControlReceiver` +3. 系统权限是否充足 + +祝你使用愉快!🎉 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6fc1ab655..1fb2814856 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -186,6 +186,16 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/github/kr328/clash/ExternalControlReceiver.kt b/app/src/main/java/com/github/kr328/clash/ExternalControlReceiver.kt new file mode 100644 index 0000000000..358b5b3b48 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/ExternalControlReceiver.kt @@ -0,0 +1,72 @@ +package com.github.kr328.clash + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.github.kr328.clash.remote.Remote +import com.github.kr328.clash.util.startClashService +import com.github.kr328.clash.util.stopClashService + +/** + * ExternalControlReceiver - 用于 Tasker 等自动化工具的后台控制接收器 + * + * 直接在 onReceive 中执行操作,无需启动 Activity。 + * + * 使用方法(Tasker): + * - 动作类型:Send Intent + * - Action:com.github.metacubex.clash.meta.action.START_CLASH (或 STOP_CLASH / TOGGLE_CLASH) + * - Target:Broadcast Receiver + * - Package:com.github.metacubex.clash.meta + */ +class ExternalControlReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "ExternalControlReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "收到广播: action=${intent.action}") + + // 直接执行操作 + when (intent.action) { + "com.github.metacubex.clash.meta.action.START_CLASH" -> { + Log.d(TAG, "处理 START_CLASH,当前状态: ${Remote.broadcasts.clashRunning}") + if (!Remote.broadcasts.clashRunning) { + val vpnRequest = context.startClashService() + if (vpnRequest != null) { + Log.e(TAG, "需要 VPN 权限,请先在应用中手动启动一次") + } else { + Log.d(TAG, "Clash 服务已启动") + } + } else { + Log.d(TAG, "Clash 已在运行") + } + } + + "com.github.metacubex.clash.meta.action.STOP_CLASH" -> { + Log.d(TAG, "处理 STOP_CLASH,当前状态: ${Remote.broadcasts.clashRunning}") + if (Remote.broadcasts.clashRunning) { + context.stopClashService() + Log.d(TAG, "Clash 服务已停止") + } else { + Log.d(TAG, "Clash 未在运行") + } + } + + "com.github.metacubex.clash.meta.action.TOGGLE_CLASH" -> { + Log.d(TAG, "处理 TOGGLE_CLASH,当前状态: ${Remote.broadcasts.clashRunning}") + if (Remote.broadcasts.clashRunning) { + context.stopClashService() + Log.d(TAG, "Clash 服务已停止") + } else { + val vpnRequest = context.startClashService() + if (vpnRequest != null) { + Log.e(TAG, "需要 VPN 权限,请先在应用中手动启动一次") + } else { + Log.d(TAG, "Clash 服务已启动") + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/kr328/clash/KeepAliveService.kt b/app/src/main/java/com/github/kr328/clash/KeepAliveService.kt new file mode 100644 index 0000000000..d9abb78acd --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/KeepAliveService.kt @@ -0,0 +1,117 @@ +package com.github.kr328.clash + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat + +/** + * KeepAliveService - 保持应用活跃的前台服务 + * + * 用途:确保 ExternalControlReceiver 能可靠地接收来自 Tasker 等自动化工具的广播。 + * 通过运行一个轻量级的前台服务,防止应用进入深度休眠状态(CACHED_EMPTY), + * 从而让 BroadcastReceiver 能够接收到第三方应用发送的广播。 + * + * 特性: + * - 使用最低优先级的通知(IMPORTANCE_MIN),不会打扰用户 + * - 几乎不消耗系统资源,仅用于保持进程优先级 + * - 在应用启动时自动启动,确保自动化功能始终可用 + */ +class KeepAliveService : Service() { + companion object { + private const val TAG = "KeepAliveService" + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "clash_keepalive" + private const val CHANNEL_NAME = "后台自动化" + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "KeepAliveService 已创建") + + // 创建通知渠道(Android 8.0+) + createNotificationChannel() + + // 启动前台服务 + val notification = createNotification() + startForeground(NOTIFICATION_ID, notification) + + Log.d(TAG, "前台服务已启动,应用将保持活跃以接收自动化广播") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand: 服务正在运行") + // START_STICKY: 如果服务被系统杀死,会自动重启 + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "KeepAliveService 已销毁") + } + + override fun onBind(intent: Intent?): IBinder? { + // 这是一个纯前台服务,不提供绑定 + return null + } + + /** + * 创建通知渠道(Android 8.0+ 必需) + */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = getSystemService(NotificationManager::class.java) + + // 使用 IMPORTANCE_MIN,通知会被最小化,不会发出声音和震动 + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_MIN + ).apply { + description = "保持应用活跃,确保 Tasker 等自动化工具能正常控制 Clash" + setShowBadge(false) // 不显示角标 + enableLights(false) // 不闪烁指示灯 + enableVibration(false) // 不震动 + setSound(null, null) // 不发出声音 + } + + notificationManager.createNotificationChannel(channel) + Log.d(TAG, "通知渠道已创建: $CHANNEL_ID") + } + } + + /** + * 创建前台服务通知 + */ + private fun createNotification(): Notification { + // 点击通知时打开主界面 + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Clash 服务") + .setContentText("运行中") + .setSmallIcon(R.mipmap.ic_launcher) // 使用应用启动器图标 + .setOngoing(true) // 不可滑动清除 + .setPriority(NotificationCompat.PRIORITY_MIN) // 最低优先级 + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + // 设置点击事件 + builder.setContentIntent(pendingIntent) + + return builder.build() + } +} diff --git a/app/src/main/java/com/github/kr328/clash/MainApplication.kt b/app/src/main/java/com/github/kr328/clash/MainApplication.kt index 6bef37f9b6..22914d0d52 100644 --- a/app/src/main/java/com/github/kr328/clash/MainApplication.kt +++ b/app/src/main/java/com/github/kr328/clash/MainApplication.kt @@ -2,6 +2,8 @@ package com.github.kr328.clash import android.app.Application import android.content.Context +import android.content.Intent +import android.os.Build import com.github.kr328.clash.common.Global import com.github.kr328.clash.common.compat.currentProcessName import com.github.kr328.clash.common.log.Log @@ -30,6 +32,8 @@ class MainApplication : Application() { if (processName == packageName) { Remote.launch() + // 启动 KeepAlive Service 以确保自动化广播能可靠接收 + startKeepAliveService() } else { sendServiceRecreated() } @@ -70,6 +74,24 @@ class MainApplication : Application() { } } + /** + * 启动 KeepAlive Service 以保持应用活跃 + * 确保 ExternalControlReceiver 能可靠接收来自 Tasker 等自动化工具的广播 + */ + private fun startKeepAliveService() { + try { + val intent = Intent(this, KeepAliveService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + Log.d("KeepAlive Service started successfully") + } catch (e: Exception) { + Log.e("Failed to start KeepAlive Service", e) + } + } + fun finalize() { Global.destroy() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..71d2aed94e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 后台自动化保活服务 +