feat: publish workphone SDK deployment and API docs
This commit is contained in:
13
sdk/android-app/.settings/org.eclipse.buildship.core.prefs
Normal file
13
sdk/android-app/.settings/org.eclipse.buildship.core.prefs
Normal file
@@ -0,0 +1,13 @@
|
||||
arguments=--init-script /var/folders/f_/vdpn2mcn2bvcpsvvxvdwkqhh0000gn/T/db3b08fc4a9ef609cb16b96b200fa13e563f396e9bb1ed0905fdab7bc3bc513b.gradle --init-script /var/folders/f_/vdpn2mcn2bvcpsvvxvdwkqhh0000gn/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle --init-script /var/folders/f_/vdpn2mcn2bvcpsvvxvdwkqhh0000gn/T/861a75667e10803d304a058d833cb7404195ca44013d0d61d3b653eb084379b8.gradle --init-script /var/folders/f_/vdpn2mcn2bvcpsvvxvdwkqhh0000gn/T/68eb1b6516fe21c6fbba58e63c99c3207ccfc918360613709367eecde56fa77f.gradle --init-script /var/folders/f_/vdpn2mcn2bvcpsvvxvdwkqhh0000gn/T/da64152279c70a8b4f3de4ca9ea66fd3b3405b7aca4e1f20f2d08e5593aa1ce1.gradle
|
||||
auto.sync=true
|
||||
build.scans.enabled=false
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
|
||||
connection.project.dir=app
|
||||
eclipse.preferences.version=1
|
||||
gradle.user.home=
|
||||
java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
|
||||
jvm.arguments=
|
||||
offline.mode=false
|
||||
override.workspace.settings=true
|
||||
show.console.view=true
|
||||
show.executions.view=true
|
||||
194
sdk/android-app/README.md
Normal file
194
sdk/android-app/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 工作手机Agent APP
|
||||
|
||||
> 安装在手机上,实现远程控制和项目绑定
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **远程服务器连接** - WebSocket长连接
|
||||
- ✅ **项目绑定** - 每台手机绑定指定项目
|
||||
- ✅ **后台运行** - 前台服务保持运行
|
||||
- ✅ **开机自启** - 系统启动后自动连接
|
||||
- ✅ **断线重连** - 网络恢复后自动重连
|
||||
- ✅ **远程命令执行** - 接收服务器指令并执行
|
||||
|
||||
---
|
||||
|
||||
## 安装方式
|
||||
|
||||
### 方式1: 直接安装APK
|
||||
|
||||
1. 从Release页面下载 `WorkPhoneAgent.apk`
|
||||
2. 传输到手机并安装
|
||||
3. 允许"安装未知来源应用"权限
|
||||
|
||||
### 方式2: 源码编译
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd sdk/android-app
|
||||
|
||||
# 使用Gradle编译
|
||||
./gradlew assembleDebug
|
||||
|
||||
# APK位置
|
||||
# app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 1. 首次配置
|
||||
|
||||
1. 打开APP
|
||||
2. 填写**服务器地址**: `ws://sdk.quwanzhi.com:8899/ws/device`
|
||||
3. 填写**项目ID**: 从管理后台获取
|
||||
4. 设备ID默认自动生成为奥创兼容格式:`md5(android_id)`(可自定义覆盖)
|
||||
5. 点击"连接服务器"
|
||||
|
||||
### 2. 状态说明
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| 未连接 | 服务未启动 |
|
||||
| 正在连接... | 正在建立WebSocket连接 |
|
||||
| 已连接 | 成功连接服务器 |
|
||||
| 已断开 | 连接已断开 |
|
||||
| 连接错误 | 网络或服务器异常 |
|
||||
|
||||
### 3. 后台运行
|
||||
|
||||
- 连接成功后,服务会在后台持续运行
|
||||
- 通知栏会显示"工作手机Agent - 已连接"
|
||||
- 即使关闭APP,服务依然运行
|
||||
- 开机后自动启动并连接
|
||||
|
||||
---
|
||||
|
||||
## 服务器端配置
|
||||
|
||||
### WebSocket端点
|
||||
|
||||
服务器需要提供WebSocket端点接收设备连接:
|
||||
|
||||
```
|
||||
ws://your-server:8899/ws/device/{device_id}
|
||||
```
|
||||
|
||||
### 消息格式
|
||||
|
||||
**设备注册消息:**
|
||||
```json
|
||||
{
|
||||
"type": "register",
|
||||
"device_id": "device_001",
|
||||
"project_id": "project_xxx",
|
||||
"platform": "android",
|
||||
"model": "Redmi Note 13",
|
||||
"sdk_version": 34,
|
||||
"app_version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**心跳消息(默认30秒,可远程配置):**
|
||||
```json
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"device_id": "device_001",
|
||||
"timestamp": 1706000000000
|
||||
}
|
||||
```
|
||||
|
||||
**心跳配置(服务器下发):**
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"heartbeat_interval_seconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
**执行命令(服务器发送):**
|
||||
```json
|
||||
{
|
||||
"type": "execute",
|
||||
"command_id": "cmd_001",
|
||||
"action": "open_app",
|
||||
"params": {
|
||||
"package": "com.tencent.mm"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**命令结果(设备返回):**
|
||||
```json
|
||||
{
|
||||
"type": "result",
|
||||
"command_id": "cmd_001",
|
||||
"device_id": "device_001",
|
||||
"success": true,
|
||||
"message": "已打开 com.tencent.mm",
|
||||
"timestamp": 1706000001000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 支持的命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| open_app | package: 包名 | 打开指定APP |
|
||||
| get_installed_apps | 无 | 获取已安装APP列表 |
|
||||
| get_device_info | 无 | 获取设备信息 |
|
||||
|
||||
---
|
||||
|
||||
## 权限说明
|
||||
|
||||
| 权限 | 用途 |
|
||||
|------|------|
|
||||
| INTERNET | 网络连接 |
|
||||
| FOREGROUND_SERVICE | 后台服务 |
|
||||
| POST_NOTIFICATIONS | 显示通知 |
|
||||
| RECEIVE_BOOT_COMPLETED | 开机自启 |
|
||||
| SYSTEM_ALERT_WINDOW | 悬浮窗(预留) |
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
android-app/
|
||||
├── app/
|
||||
│ ├── src/main/
|
||||
│ │ ├── java/com/workphone/agent/
|
||||
│ │ │ ├── MainActivity.kt # 主界面
|
||||
│ │ │ ├── AgentService.kt # 后台服务
|
||||
│ │ │ └── BootReceiver.kt # 开机启动
|
||||
│ │ ├── res/
|
||||
│ │ │ ├── layout/ # 布局文件
|
||||
│ │ │ └── values/ # 资源文件
|
||||
│ │ └── AndroidManifest.xml
|
||||
│ └── build.gradle
|
||||
├── build.gradle
|
||||
├── settings.gradle
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 编译要求
|
||||
|
||||
- Android Studio 2023.1+
|
||||
- JDK 17
|
||||
- Gradle 8.2
|
||||
- Android SDK 34
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 微信: 28533368
|
||||
- 邮箱: zhiqun@qq.com
|
||||
81
sdk/android-app/app/build.gradle
Normal file
81
sdk/android-app/app/build.gradle
Normal file
@@ -0,0 +1,81 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.system.cloudservice'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.system.cloudservice"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 6
|
||||
versionName "5.0.1"
|
||||
buildConfigField "String", "PWA_URL", "\"https://ckbapi.quwanzhi.com\""
|
||||
buildConfigField "String", "DEFAULT_WS", "\"wss://wpsdk.quwanzhi.com/ws/device\""
|
||||
buildConfigField "String", "AI_API_URL", "\"https://ckbapi.quwanzhi.com\""
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
// 本地/CI 全自动安装:与 debug 同签名,勿用于上架 Play
|
||||
signingConfig signingConfigs.debug
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
pickFirst 'META-INF/INDEX.LIST'
|
||||
pickFirst 'META-INF/io.netty.versions.properties'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
|
||||
// OkHttp (HTTP + WebSocket unified)
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
|
||||
// JSON
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
|
||||
// Coroutines + Flow
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
// Room (offline buffer persistence)
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
|
||||
// QR code scanning
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
implementation 'com.google.zxing:core:3.5.2'
|
||||
|
||||
// Lifecycle
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
|
||||
// Fragment KTX
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
}
|
||||
4
sdk/android-app/app/proguard-rules.pro
vendored
Normal file
4
sdk/android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# Keep WebSocket classes
|
||||
-keep class org.java_websocket.** { *; }
|
||||
-keep class com.google.gson.** { *; }
|
||||
84
sdk/android-app/app/src/main/AndroidManifest.xml
Normal file
84
sdk/android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- 仅授权测试/企业管控设备通过 ADB grant 后生效,用于自动启用本应用无障碍。 -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WorkPhoneAgent"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- 桌面显示「工作手机」入口;SetupActivity 为 Launcher,MainActivity 仍由服务/通知拉起 -->
|
||||
<activity
|
||||
android:name=".ui.SetupActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/launcher_name"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop" />
|
||||
|
||||
<service
|
||||
android:name=".service.AgentForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".service.AgentAccessibilityService"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
android:directBootAware="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
1666
sdk/android-app/app/src/main/assets/hub.html
Normal file
1666
sdk/android-app/app/src/main/assets/hub.html
Normal file
File diff suppressed because it is too large
Load Diff
3258
sdk/android-app/app/src/main/assets/wechat_hook_v2.js
Normal file
3258
sdk/android-app/app/src/main/assets/wechat_hook_v2.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
package com.system.cloudservice
|
||||
|
||||
import android.app.Application
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.util.Logger
|
||||
|
||||
class App : Application() {
|
||||
companion object {
|
||||
lateinit var instance: App
|
||||
private set
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
Logger.init(this)
|
||||
AgentAccessibilityService.enableIfAuthorized(this)
|
||||
Logger.i("工作手机 Application created (${BuildConfig.VERSION_NAME})")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.system.cloudservice.ai
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
|
||||
data class TaskItem(
|
||||
val id: String = "task_${System.currentTimeMillis()}",
|
||||
val source: String = "ai",
|
||||
val instruction: String,
|
||||
val priority: Int = 5,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
var status: String = "pending",
|
||||
var result: Map<String, Any?>? = null,
|
||||
)
|
||||
|
||||
class AIBrain(
|
||||
private val llmClient: LLMClient,
|
||||
val brainInterval: Int = 60,
|
||||
private val maxOfflineBuffer: Int = 500,
|
||||
initialStandingOrders: List<String> = emptyList(),
|
||||
var enabled: Boolean = true,
|
||||
) {
|
||||
companion object {
|
||||
const val VERSION = "1.0.0"
|
||||
private const val SYSTEM_PROMPT = """你是一个运行在 Android 手机上的 AI 助手。你的任务是根据当前手机状态和待办事项,决定下一步要执行的操作。
|
||||
|
||||
你可以控制以下 APP:微信(wechat)、抖音(douyin)、小红书(xhs)、闲鱼(xianyu)、Soul(soul)。
|
||||
|
||||
每个 APP 支持的操作(action):
|
||||
- wechat: send_message, get_messages, get_contacts, add_friend, accept_friend, post_moment, like_moment, get_groups,
|
||||
check_login_state, ensure_logged_in, login_by_password, unblock_via_customer_service
|
||||
- douyin: send_message, get_messages, get_fans, reply_comment
|
||||
- xhs: send_message, get_messages, like_note
|
||||
- xianyu: send_message, get_messages
|
||||
- soul: send_message, get_messages
|
||||
|
||||
通用操作:screenshot, click, input, swipe, app_start, app_stop, device_info
|
||||
|
||||
你必须以 JSON 格式回复,结构如下:
|
||||
{
|
||||
"should_act": true/false,
|
||||
"reason": "决策原因",
|
||||
"actions": [
|
||||
{"script": "wechat", "action": "send_message", "params": {"to": "xxx", "content": "xxx"}}
|
||||
]
|
||||
}
|
||||
|
||||
如果当前没有需要执行的任务,返回 {"should_act": false, "reason": "无待处理任务"}。
|
||||
如果设备状态异常(低电量、无网络),优先处理设备问题。"""
|
||||
}
|
||||
|
||||
val taskQueue = ConcurrentLinkedDeque<TaskItem>()
|
||||
private val offlineBuffer = ConcurrentLinkedDeque<Map<String, Any?>>()
|
||||
val standingOrders = mutableListOf<String>().apply { addAll(initialStandingOrders) }
|
||||
|
||||
var online: Boolean = true
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
Logger.i("AIBrain mode: ${if (value) "online" else "offline(autonomous)"}")
|
||||
}
|
||||
}
|
||||
|
||||
private var lastThinkTime = 0L
|
||||
private var thinkCount = 0
|
||||
private var executeCount = 0
|
||||
|
||||
/** AB-06 批量闸门:返回 false 时整批动作跳过(由 AgentEngine 注入 antiBan::canBatch) */
|
||||
var batchGate: (() -> Boolean)? = null
|
||||
|
||||
fun addTask(instruction: String, source: String = "server", priority: Int = 5): String {
|
||||
val task = TaskItem(instruction = instruction, source = source, priority = priority)
|
||||
taskQueue.add(task)
|
||||
if (taskQueue.size > 200) taskQueue.pollFirst()
|
||||
Logger.i("Task enqueued [$source]: ${instruction.take(50)}...")
|
||||
return task.id
|
||||
}
|
||||
|
||||
fun addStandingOrder(order: String) {
|
||||
if (order !in standingOrders) {
|
||||
standingOrders.add(order)
|
||||
Logger.i("Standing order added: ${order.take(50)}...")
|
||||
}
|
||||
}
|
||||
|
||||
fun flushOfflineBuffer(): List<Map<String, Any?>> {
|
||||
val results = offlineBuffer.toList()
|
||||
offlineBuffer.clear()
|
||||
return results
|
||||
}
|
||||
|
||||
suspend fun heartbeatCycle(
|
||||
deviceStatus: Map<String, Any?>,
|
||||
executeFn: suspend (script: String, action: String, params: Map<String, Any?>) -> Map<String, Any?>
|
||||
): Map<String, Any?> {
|
||||
if (!enabled) return mapOf("thought" to false, "acted" to false)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastThinkTime < brainInterval * 1000L) {
|
||||
return mapOf("thought" to false, "acted" to false, "skip" to "interval")
|
||||
}
|
||||
|
||||
lastThinkTime = now
|
||||
thinkCount++
|
||||
|
||||
val pending = taskQueue.filter { it.status == "pending" }.map { it.instruction }
|
||||
val decision = think(deviceStatus, pending.takeIf { it.isNotEmpty() })
|
||||
?: return mapOf("thought" to true, "acted" to false, "reason" to "AI无响应")
|
||||
|
||||
if (decision["should_act"] != true) {
|
||||
return mapOf("thought" to true, "acted" to false, "reason" to decision["reason"])
|
||||
}
|
||||
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val actions = decision["actions"] as? List<Map<String, Any?>> ?: emptyList()
|
||||
|
||||
// AB-06 高风险禁批量:level>=3 时整批跳过(NurtureScheduler 已同步暂停)
|
||||
if (actions.isNotEmpty() && batchGate?.invoke() == false) {
|
||||
Logger.w("AIBrain heartbeat: batch blocked by risk gate (level>=3)")
|
||||
return mapOf("thought" to true, "acted" to false, "reason" to "风控熔断,批量已禁")
|
||||
}
|
||||
|
||||
for (spec in actions) {
|
||||
val script = spec["script"] as? String ?: continue
|
||||
val action = spec["action"] as? String ?: continue
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = spec["params"] as? Map<String, Any?> ?: emptyMap()
|
||||
|
||||
try {
|
||||
val result = executeFn(script, action, params)
|
||||
executeCount++
|
||||
val entry = mapOf(
|
||||
"script" to script, "action" to action, "params" to params,
|
||||
"result" to result, "timestamp" to System.currentTimeMillis()
|
||||
)
|
||||
results.add(entry)
|
||||
if (!online) offlineBuffer.add(entry.plus("buffered_at" to System.currentTimeMillis()))
|
||||
} catch (e: Exception) {
|
||||
Logger.e("AIBrain execute failed [$script.$action]", e)
|
||||
results.add(mapOf("script" to script, "action" to action, "error" to e.message))
|
||||
}
|
||||
}
|
||||
|
||||
taskQueue.filter { it.status == "pending" }.forEach { it.status = "done" }
|
||||
|
||||
return mapOf("thought" to true, "acted" to true, "results" to results, "reason" to (decision["reason"] ?: ""))
|
||||
}
|
||||
|
||||
/** 用户对话:立即走卡若网关决策并执行(不经心跳间隔) */
|
||||
suspend fun chatAndExecute(
|
||||
userInstruction: String,
|
||||
deviceStatus: Map<String, Any?>,
|
||||
executeFn: suspend (script: String, action: String, params: Map<String, Any?>) -> Map<String, Any?>
|
||||
): Map<String, Any?> {
|
||||
if (!enabled) return mapOf("code" to 503, "message" to "AI Brain 未启用")
|
||||
addTask(userInstruction, "user", 10)
|
||||
thinkCount++
|
||||
lastThinkTime = System.currentTimeMillis()
|
||||
|
||||
val decision = think(deviceStatus, listOf(userInstruction))
|
||||
?: return mapOf("code" to 503, "message" to "卡若AI 无响应", "instruction" to userInstruction)
|
||||
|
||||
if (decision["should_act"] != true) {
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"acted" to false,
|
||||
"reason" to (decision["reason"] ?: "无需操作"),
|
||||
"instruction" to userInstruction,
|
||||
)
|
||||
}
|
||||
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val actions = decision["actions"] as? List<Map<String, Any?>> ?: emptyList()
|
||||
|
||||
// AB-06 高风险禁批量:level>=3 时整批跳过(与 heartbeatCycle 一致)
|
||||
if (actions.isNotEmpty() && batchGate?.invoke() == false) {
|
||||
Logger.w("AIBrain chat: batch blocked by risk gate (level>=3)")
|
||||
return mapOf("code" to 429, "acted" to false, "reason" to "风控熔断,批量已禁", "instruction" to userInstruction)
|
||||
}
|
||||
|
||||
for (spec in actions) {
|
||||
val script = spec["script"] as? String ?: continue
|
||||
val action = spec["action"] as? String ?: continue
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = spec["params"] as? Map<String, Any?> ?: emptyMap()
|
||||
try {
|
||||
val result = executeFn(script, action, params)
|
||||
executeCount++
|
||||
results.add(mapOf("script" to script, "action" to action, "params" to params, "result" to result))
|
||||
} catch (e: Exception) {
|
||||
results.add(mapOf("script" to script, "action" to action, "error" to e.message))
|
||||
}
|
||||
}
|
||||
taskQueue.filter { it.status == "pending" }.forEach { it.status = "done" }
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"acted" to true,
|
||||
"results" to results,
|
||||
"reason" to (decision["reason"] ?: ""),
|
||||
"instruction" to userInstruction,
|
||||
"gateway" to "卡若AI /api/gateway/chat",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun think(
|
||||
deviceStatus: Map<String, Any?>,
|
||||
pendingInstructions: List<String>?
|
||||
): Map<String, Any?>? {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("当前时间: ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date())}")
|
||||
parts.add("设备状态: ${com.google.gson.Gson().toJson(deviceStatus)}")
|
||||
|
||||
if (!online) parts.add("⚠️ 当前处于离线模式(服务器未连接),需要自主决策")
|
||||
if (pendingInstructions != null) parts.add("待处理任务: ${com.google.gson.Gson().toJson(pendingInstructions)}")
|
||||
if (standingOrders.isNotEmpty() && !online) parts.add("常驻指令: ${com.google.gson.Gson().toJson(standingOrders)}")
|
||||
|
||||
val messages = listOf(
|
||||
mapOf("role" to "system", "content" to SYSTEM_PROMPT),
|
||||
mapOf("role" to "user", "content" to parts.joinToString("\n")),
|
||||
)
|
||||
|
||||
val response = llmClient.chat(messages) ?: return null
|
||||
|
||||
return try {
|
||||
val start = response.indexOf("{")
|
||||
val end = response.lastIndexOf("}") + 1
|
||||
if (start >= 0 && end > start) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
com.google.gson.Gson().fromJson(response.substring(start, end), Map::class.java) as Map<String, Any?>
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
Logger.w("AI response parse failed: ${response.take(200)}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatus(): Map<String, Any?> = mapOf(
|
||||
"enabled" to enabled,
|
||||
"online" to online,
|
||||
"brain_interval" to brainInterval,
|
||||
"think_count" to thinkCount,
|
||||
"execute_count" to executeCount,
|
||||
"task_queue_size" to taskQueue.size,
|
||||
"offline_buffer_size" to offlineBuffer.size,
|
||||
"standing_orders" to standingOrders.size,
|
||||
"version" to VERSION,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.system.cloudservice.ai
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class LLMClient(
|
||||
private val apiUrl: String,
|
||||
private val apiKey: String,
|
||||
private val model: String = "auto",
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun chat(messages: List<Map<String, String>>, maxTokens: Int = 1024): String? {
|
||||
val url = "${apiUrl.trimEnd('/')}/api/gateway/chat"
|
||||
val payload = mutableMapOf<String, Any>(
|
||||
"messages" to messages,
|
||||
"max_tokens" to maxTokens,
|
||||
)
|
||||
if (model.isNotEmpty() && model != "auto") payload["model"] = model
|
||||
|
||||
val body = gson.toJson(payload).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.addHeader("Authorization", "Bearer $apiKey")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Logger.e("LLM API ${response.code}: ${response.body?.string()?.take(200)}")
|
||||
return@withContext null
|
||||
}
|
||||
val json = response.body?.string() ?: return@withContext null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val data = gson.fromJson(json, Map::class.java) as Map<String, Any?>
|
||||
val choices = data["choices"] as? List<*> ?: return@withContext null
|
||||
val first = choices.firstOrNull() as? Map<*, *> ?: return@withContext null
|
||||
val message = first["message"] as? Map<*, *> ?: return@withContext null
|
||||
message["content"] as? String
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("LLM API call failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.system.cloudservice.antiban
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class AntiBanManager(
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val nurtureScheduler = NurtureScheduler(phase = "new")
|
||||
val riskSentinel = RiskSentinel { level, msg ->
|
||||
Logger.w("RiskAlert Lv$level: $msg")
|
||||
// AB-02 熔断联动:level>=3 暂停暖机,level<=1 恢复
|
||||
when {
|
||||
level >= 3 -> nurtureScheduler.pause()
|
||||
level <= 1 -> nurtureScheduler.resume()
|
||||
}
|
||||
}
|
||||
val deviceGuard = DeviceGuard()
|
||||
val touchHardener = TouchHardener()
|
||||
val sensorSimulator = SensorSimulator(scope)
|
||||
|
||||
private var guardReport: DeviceGuard.CheckResult? = null
|
||||
|
||||
fun init() {
|
||||
Logger.i("AntiBanManager initializing...")
|
||||
guardReport = deviceGuard.runFullCheck()
|
||||
if (guardReport!!.warnings.isNotEmpty()) {
|
||||
Logger.w("DeviceGuard found ${guardReport!!.warnings.size} warnings: ${guardReport!!.warnings}")
|
||||
}
|
||||
sensorSimulator.start(3f)
|
||||
Logger.i("AntiBanManager ready")
|
||||
}
|
||||
|
||||
fun canOperate(action: String? = null): Boolean {
|
||||
return riskSentinel.canOperate(action)
|
||||
}
|
||||
|
||||
/** AB-06:level<3 才允许批量任务入队/执行(与 NurtureScheduler.pause 协同) */
|
||||
fun canBatch(): Boolean = riskSentinel.currentLevel() < 3
|
||||
|
||||
fun riskStatus(): Map<String, Any?> = riskSentinel.getStatus()
|
||||
|
||||
fun statusSummary(): Map<String, Any?> = mapOf(
|
||||
"risk_sentinel" to riskSentinel.getStatus(),
|
||||
"nurture" to nurtureScheduler.getStatus(),
|
||||
"device_guard" to (guardReport?.info ?: emptyMap()),
|
||||
"sensor_simulator" to true,
|
||||
"touch_hardener" to true,
|
||||
)
|
||||
|
||||
fun stop() {
|
||||
sensorSimulator.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.system.cloudservice.antiban
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
|
||||
class DeviceGuard {
|
||||
|
||||
data class CheckResult(
|
||||
val warnings: List<String>,
|
||||
val info: Map<String, Any?>,
|
||||
)
|
||||
|
||||
fun runFullCheck(): CheckResult {
|
||||
val warnings = mutableListOf<String>()
|
||||
val info = mutableMapOf<String, Any?>()
|
||||
|
||||
info["root_detected"] = checkRoot().also { if (it) Logger.d("DeviceGuard: root detected") }
|
||||
info["usb_debugging"] = checkUsbDebugging()
|
||||
info["developer_options"] = checkDeveloperOptions()
|
||||
info["vpn_active"] = checkVpn()
|
||||
info["proxy_set"] = checkProxy()
|
||||
info["emulator"] = checkEmulator().also { if (it) warnings.add("检测到模拟器环境") }
|
||||
info["xposed_detected"] = checkXposed().also { if (it) warnings.add("检测到Xposed框架") }
|
||||
info["frida_detected"] = checkFridaFromOutside().also { if (it) warnings.add("检测到外部Frida") }
|
||||
info["selinux"] = getSELinuxStatus()
|
||||
|
||||
val magisk = checkMagisk()
|
||||
info["magisk_hidden"] = magisk["hidden"]
|
||||
if (magisk["detected"] == true && magisk["hidden"] != true) {
|
||||
warnings.add("Magisk未隐藏,建议开启Zygisk+DenyList")
|
||||
}
|
||||
|
||||
info["warning_count"] = warnings.size
|
||||
return CheckResult(warnings, info)
|
||||
}
|
||||
|
||||
private fun checkRoot(): Boolean {
|
||||
val paths = listOf("/system/xbin/su", "/system/bin/su", "/sbin/su", "/data/local/xbin/su")
|
||||
for (p in paths) {
|
||||
val r = ShellExecutor.execute("ls $p", 2)
|
||||
if (r.success) return true
|
||||
}
|
||||
return ShellExecutor.hasRoot()
|
||||
}
|
||||
|
||||
private fun checkUsbDebugging(): Boolean {
|
||||
val r = ShellExecutor.execute("settings get global adb_enabled", 2)
|
||||
return r.success && r.output.trim() == "1"
|
||||
}
|
||||
|
||||
private fun checkDeveloperOptions(): Boolean {
|
||||
val r = ShellExecutor.execute("settings get global development_settings_enabled", 2)
|
||||
return r.success && r.output.trim() == "1"
|
||||
}
|
||||
|
||||
private fun checkVpn(): Boolean {
|
||||
val r = ShellExecutor.execute("ip tun show 2>/dev/null || ip link show tun0 2>/dev/null", 2)
|
||||
return r.success && r.output.isNotBlank()
|
||||
}
|
||||
|
||||
private fun checkProxy(): Boolean {
|
||||
val r = ShellExecutor.execute("settings get global http_proxy", 2)
|
||||
return r.success && r.output.trim().let { it.isNotEmpty() && it != ":0" && it != "null" }
|
||||
}
|
||||
|
||||
private fun checkEmulator(): Boolean {
|
||||
val props = listOf("ro.kernel.qemu", "ro.hardware.virtual_device", "init.svc.qemud")
|
||||
for (prop in props) {
|
||||
val r = ShellExecutor.execute("getprop $prop", 2)
|
||||
if (r.success && r.output.trim().let { it == "1" || it.isNotEmpty() }) return true
|
||||
}
|
||||
val build = ShellExecutor.execute("getprop ro.product.model", 2)
|
||||
if (build.success && build.output.contains("sdk", ignoreCase = true)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun checkXposed(): Boolean {
|
||||
val r = ShellExecutor.execute("ls /data/data/de.robv.android.xposed.installer 2>/dev/null || ls /data/data/org.meowcat.edxposed.manager 2>/dev/null || ls /data/adb/lspd 2>/dev/null", 3)
|
||||
return r.success && r.output.isNotBlank()
|
||||
}
|
||||
|
||||
private fun checkFridaFromOutside(): Boolean {
|
||||
val r = ShellExecutor.execute("cat /proc/net/tcp 2>/dev/null | grep ':69B2'", 2)
|
||||
return r.success && r.output.isNotBlank()
|
||||
}
|
||||
|
||||
private fun checkMagisk(): Map<String, Any?> {
|
||||
val detected = ShellExecutor.execute("ls /data/adb/magisk 2>/dev/null", 2).success
|
||||
val hidden = if (detected) {
|
||||
val r = ShellExecutor.execute("magisk --denylist ls 2>/dev/null", 3)
|
||||
r.success
|
||||
} else false
|
||||
return mapOf("detected" to detected, "hidden" to hidden)
|
||||
}
|
||||
|
||||
private fun getSELinuxStatus(): String {
|
||||
val r = ShellExecutor.execute("getenforce", 2)
|
||||
return if (r.success) r.output.trim().lowercase() else "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.system.cloudservice.antiban
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import java.util.*
|
||||
import kotlin.random.Random
|
||||
|
||||
class NurtureScheduler(
|
||||
private val phase: String = "new",
|
||||
private val accountAgeDays: Int = 0,
|
||||
) {
|
||||
companion object {
|
||||
private val NURTURE_ACTIONS = listOf(
|
||||
NurtureAction("browse_moments", "浏览朋友圈", 3, 120..300),
|
||||
NurtureAction("browse_discover", "浏览发现页", 2, 60..180),
|
||||
NurtureAction("read_articles", "阅读文章", 2, 90..240),
|
||||
NurtureAction("check_contacts", "查看通讯录", 1, 30..60),
|
||||
NurtureAction("browse_miniprogram", "浏览小程序", 1, 60..120),
|
||||
)
|
||||
|
||||
private val PHASE_LIMITS = mapOf(
|
||||
"new" to PhaseConfig(maxDailyActions = 5, minIntervalMin = 60, allowedHours = 9..21),
|
||||
"warming" to PhaseConfig(maxDailyActions = 10, minIntervalMin = 30, allowedHours = 8..22),
|
||||
"active" to PhaseConfig(maxDailyActions = 20, minIntervalMin = 15, allowedHours = 7..23),
|
||||
)
|
||||
}
|
||||
|
||||
private data class NurtureAction(
|
||||
val action: String, val name: String,
|
||||
val weight: Int, val durationRange: IntRange,
|
||||
)
|
||||
|
||||
private data class PhaseConfig(
|
||||
val maxDailyActions: Int, val minIntervalMin: Int, val allowedHours: IntRange,
|
||||
)
|
||||
|
||||
private var dailyActionCount = 0
|
||||
private var lastActionAt = 0L
|
||||
private var lastResetDay = -1
|
||||
|
||||
@Volatile private var paused = false
|
||||
|
||||
/** AB-02 熔断时暂停暖机;AB-03 降速时仍允许(shouldNurture 自身频控) */
|
||||
fun pause() { paused = true; Logger.i("NurtureScheduler paused (risk circuit breaker)") }
|
||||
fun resume() { if (paused) { paused = false; Logger.i("NurtureScheduler resumed") } }
|
||||
fun isPaused(): Boolean = paused
|
||||
|
||||
fun shouldNurture(): Boolean {
|
||||
if (paused) return false
|
||||
val now = Calendar.getInstance()
|
||||
val day = now.get(Calendar.DAY_OF_YEAR)
|
||||
if (day != lastResetDay) {
|
||||
dailyActionCount = 0
|
||||
lastResetDay = day
|
||||
}
|
||||
val config = PHASE_LIMITS[phase] ?: PHASE_LIMITS["new"]!!
|
||||
val hour = now.get(Calendar.HOUR_OF_DAY)
|
||||
if (hour !in config.allowedHours) return false
|
||||
if (dailyActionCount >= config.maxDailyActions) return false
|
||||
val elapsed = (System.currentTimeMillis() - lastActionAt) / 60_000
|
||||
return elapsed >= config.minIntervalMin
|
||||
}
|
||||
|
||||
fun pickAction(): Map<String, Any> {
|
||||
val weighted = NURTURE_ACTIONS.flatMap { a -> List(a.weight) { a } }
|
||||
val chosen = weighted[Random.nextInt(weighted.size)]
|
||||
val duration = Random.nextInt(chosen.durationRange.first, chosen.durationRange.last + 1)
|
||||
return mapOf(
|
||||
"action" to chosen.action,
|
||||
"name" to chosen.name,
|
||||
"duration_sec" to duration,
|
||||
)
|
||||
}
|
||||
|
||||
fun recordAction(action: String) {
|
||||
dailyActionCount++
|
||||
lastActionAt = System.currentTimeMillis()
|
||||
Logger.i("Nurture: $action (daily count: $dailyActionCount)")
|
||||
}
|
||||
|
||||
fun getStatus(): Map<String, Any?> = mapOf(
|
||||
"phase" to phase,
|
||||
"account_age_days" to accountAgeDays,
|
||||
"daily_action_count" to dailyActionCount,
|
||||
"last_action_at" to lastActionAt,
|
||||
"paused" to paused,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.system.cloudservice.antiban
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class RiskSentinel(
|
||||
private val onAlert: ((level: Int, message: String) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val WINDOW_MS = 60_000L
|
||||
private const val HOURLY_MS = 3_600_000L
|
||||
// AB-01:与服务端 rate_limiter 量产阈值对齐。2026 全网共识:发送 >20 条/分钟
|
||||
// 持续即触发临时限制,故分钟上限收敛到 20;小时上限 500→300 更接近真人节律。
|
||||
private const val MAX_PER_MINUTE = 20
|
||||
private const val MAX_PER_HOUR = 300
|
||||
// 连续失败冷却由 30s 提升到 120s,避免被静默降权后快速重试加重风控。
|
||||
private const val FAILURE_COOLDOWN_MS = 120_000L
|
||||
private const val MAX_CONSECUTIVE_FAILURES = 3
|
||||
}
|
||||
|
||||
private val minuteCounter = AtomicInteger(0)
|
||||
private val hourCounter = AtomicInteger(0)
|
||||
private var minuteWindowStart = System.currentTimeMillis()
|
||||
private var hourWindowStart = System.currentTimeMillis()
|
||||
private var consecutiveFailures = 0
|
||||
private var cooldownUntil = 0L
|
||||
private var riskLevel = 0
|
||||
private val actionCounts = ConcurrentHashMap<String, AtomicInteger>()
|
||||
|
||||
fun canOperate(action: String? = null): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now < cooldownUntil) return false
|
||||
resetWindowsIfNeeded(now)
|
||||
return minuteCounter.get() < MAX_PER_MINUTE && hourCounter.get() < MAX_PER_HOUR
|
||||
}
|
||||
|
||||
/** AB-06:当前风控等级(0正常/1注意/2警告/3暂停),供批量任务闸门判断 */
|
||||
fun currentLevel(): Int = riskLevel
|
||||
|
||||
fun recordAction(action: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
resetWindowsIfNeeded(now)
|
||||
minuteCounter.incrementAndGet()
|
||||
hourCounter.incrementAndGet()
|
||||
actionCounts.getOrPut(action) { AtomicInteger(0) }.incrementAndGet()
|
||||
consecutiveFailures = 0
|
||||
checkThresholds()
|
||||
}
|
||||
|
||||
fun reportFailure(error: String) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
riskLevel = 3
|
||||
cooldownUntil = System.currentTimeMillis() + FAILURE_COOLDOWN_MS
|
||||
Logger.w("RiskSentinel: $MAX_CONSECUTIVE_FAILURES consecutive failures, cooldown ${FAILURE_COOLDOWN_MS / 1000}s")
|
||||
onAlert?.invoke(3, "连续${MAX_CONSECUTIVE_FAILURES}次失败,暂停${FAILURE_COOLDOWN_MS / 1000}s")
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatus(): Map<String, Any?> = mapOf(
|
||||
"level" to riskLevel,
|
||||
"level_name" to when (riskLevel) { 0 -> "正常"; 1 -> "注意"; 2 -> "警告"; 3 -> "暂停"; else -> "未知" },
|
||||
"minute_count" to minuteCounter.get(),
|
||||
"hour_count" to hourCounter.get(),
|
||||
"consecutive_failures" to consecutiveFailures,
|
||||
"cooldown_remaining_ms" to maxOf(0, cooldownUntil - System.currentTimeMillis()),
|
||||
)
|
||||
|
||||
private fun resetWindowsIfNeeded(now: Long) {
|
||||
if (now - minuteWindowStart > WINDOW_MS) {
|
||||
minuteCounter.set(0)
|
||||
minuteWindowStart = now
|
||||
}
|
||||
if (now - hourWindowStart > HOURLY_MS) {
|
||||
hourCounter.set(0)
|
||||
hourWindowStart = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkThresholds() {
|
||||
val prev = riskLevel
|
||||
riskLevel = when {
|
||||
minuteCounter.get() > MAX_PER_MINUTE * 0.8 || hourCounter.get() > MAX_PER_HOUR * 0.8 -> 2
|
||||
minuteCounter.get() > MAX_PER_MINUTE * 0.5 || hourCounter.get() > MAX_PER_HOUR * 0.5 -> 1
|
||||
else -> 0
|
||||
}
|
||||
if (riskLevel > prev) {
|
||||
val msg = "风控等级升至 $riskLevel (${minuteCounter.get()}/min, ${hourCounter.get()}/hr)"
|
||||
Logger.w("RiskSentinel: $msg")
|
||||
onAlert?.invoke(riskLevel, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.system.cloudservice.antiban
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
class SensorSimulator(
|
||||
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
) {
|
||||
private var job: Job? = null
|
||||
private var running = false
|
||||
|
||||
fun start(intervalSec: Float = 3f) {
|
||||
if (!ShellExecutor.hasRoot()) {
|
||||
Logger.w("SensorSimulator: requires root, skipped")
|
||||
return
|
||||
}
|
||||
running = true
|
||||
job = scope.launch {
|
||||
Logger.i("SensorSimulator started (interval=${intervalSec}s)")
|
||||
var tick = 0
|
||||
while (isActive && running) {
|
||||
delay((intervalSec * 1000).toLong())
|
||||
tick++
|
||||
try {
|
||||
simulateAccelerometer(tick)
|
||||
if (tick % 5 == 0) simulateGyroscope(tick)
|
||||
} catch (e: Exception) {
|
||||
Logger.d("SensorSimulator tick error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
job?.cancel()
|
||||
Logger.i("SensorSimulator stopped")
|
||||
}
|
||||
|
||||
private fun simulateAccelerometer(tick: Int) {
|
||||
val x = Random.nextFloat() * 0.3f - 0.15f
|
||||
val y = Random.nextFloat() * 0.3f - 0.15f
|
||||
val z = 9.81f + Random.nextFloat() * 0.2f - 0.1f + 0.05f * sin(tick * 0.1f).toFloat()
|
||||
ShellExecutor.execute("su -c 'echo $x $y $z > /dev/input/accel_inject 2>/dev/null'", 2)
|
||||
}
|
||||
|
||||
private fun simulateGyroscope(tick: Int) {
|
||||
val rx = Random.nextFloat() * 0.01f - 0.005f
|
||||
val ry = Random.nextFloat() * 0.01f - 0.005f
|
||||
val rz = Random.nextFloat() * 0.01f - 0.005f
|
||||
ShellExecutor.execute("su -c 'echo $rx $ry $rz > /dev/input/gyro_inject 2>/dev/null'", 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.system.cloudservice.antiban
|
||||
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Path
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
class TouchHardener {
|
||||
|
||||
fun humanizedClick(x: Int, y: Int): Boolean {
|
||||
val a11y = AgentAccessibilityService.getInstance() ?: return false
|
||||
val (hx, hy) = addJitter(x.toFloat(), y.toFloat())
|
||||
val duration = Random.nextLong(80, 150)
|
||||
val path = Path().apply { moveTo(hx, hy) }
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, Random.nextLong(0, 30), duration))
|
||||
.build()
|
||||
a11y.dispatchGesture(gesture, null, null)
|
||||
return true
|
||||
}
|
||||
|
||||
fun humanizedSwipe(x1: Int, y1: Int, x2: Int, y2: Int, baseDurationMs: Long = 300): Boolean {
|
||||
val a11y = AgentAccessibilityService.getInstance() ?: return false
|
||||
val path = buildBezierPath(
|
||||
addJitter(x1.toFloat(), y1.toFloat()),
|
||||
addJitter(x2.toFloat(), y2.toFloat()),
|
||||
)
|
||||
val duration = baseDurationMs + Random.nextLong(-50, 80)
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, Random.nextLong(0, 20), duration.coerceAtLeast(100)))
|
||||
.build()
|
||||
a11y.dispatchGesture(gesture, null, null)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildBezierPath(start: Pair<Float, Float>, end: Pair<Float, Float>): Path {
|
||||
val (sx, sy) = start
|
||||
val (ex, ey) = end
|
||||
val midX = (sx + ex) / 2 + Random.nextFloat() * 40 - 20
|
||||
val midY = (sy + ey) / 2 + Random.nextFloat() * 40 - 20
|
||||
return Path().apply {
|
||||
moveTo(sx, sy)
|
||||
quadTo(midX, midY, ex, ey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addJitter(x: Float, y: Float): Pair<Float, Float> {
|
||||
val radius = Random.nextFloat() * 8
|
||||
val angle = Random.nextFloat() * 2 * Math.PI.toFloat()
|
||||
return Pair(
|
||||
x + radius * cos(angle),
|
||||
y + radius * sin(angle),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.google.gson.Gson
|
||||
import com.system.cloudservice.BuildConfig
|
||||
import com.system.cloudservice.ai.AIBrain
|
||||
import com.system.cloudservice.antiban.AntiBanManager
|
||||
import com.system.cloudservice.frida.FridaBridge
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
class AgentEngine(
|
||||
private val context: Context,
|
||||
private val skillExecutor: SkillExecutor,
|
||||
private val aiBrain: AIBrain?,
|
||||
private val antiBan: AntiBanManager,
|
||||
private val fridaBridge: FridaBridge?,
|
||||
) {
|
||||
companion object {
|
||||
const val VERSION = "4.0.0"
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val wsManager = WebSocketManager(scope)
|
||||
|
||||
init {
|
||||
// AB-06:注入批量闸门,AIBrain 执行 actions 前检查 antiBan.canBatch()
|
||||
aiBrain?.batchGate = { antiBan.canBatch() }
|
||||
}
|
||||
|
||||
private var deviceId = ""
|
||||
private var projectId = ""
|
||||
private var serverUrl = ""
|
||||
private var pairingToken = ""
|
||||
private var heartbeatIntervalMs = 30_000L
|
||||
private var lastPongAt = 0L
|
||||
private var commandsExecuted = 0
|
||||
private var startTime = 0L
|
||||
|
||||
// BIND-03/05/07:公网主服有序回退 + 寻服阶段(lan/primary/public/retry)
|
||||
private var publicServers: List<String> = emptyList()
|
||||
private var candidates: List<String> = emptyList()
|
||||
private var candidateIdx = 0
|
||||
@Volatile private var connectStage = "primary"
|
||||
@Volatile private var rediscovering = false
|
||||
|
||||
private var heartbeatJob: Job? = null
|
||||
private var messageJob: Job? = null
|
||||
private var aiBrainJob: Job? = null
|
||||
private var autonomousJob: Job? = null
|
||||
|
||||
var onStateChange: ((ConnectionState) -> Unit)? = null
|
||||
|
||||
fun hasFridaBridge(): Boolean = fridaBridge != null
|
||||
|
||||
fun isFridaReady(): Boolean = fridaBridge?.isReady == true
|
||||
|
||||
/** 微信进程真实注入状态;与 frida-server 端口就绪严格分离。 */
|
||||
fun isWechatHookAttached(): Boolean = fridaBridge?.isTargetHooked() == true
|
||||
|
||||
/** AB-02~06 runtime 信号:risk_sentinel / nurture / device_guard / sensor / touch */
|
||||
fun getAntiBanStatus(): Map<String, Any?> = antiBan.statusSummary()
|
||||
|
||||
/** BIND-07 寻服阶段:primary / lan / public */
|
||||
fun getConnectStage(): String = connectStage
|
||||
|
||||
val brain: AIBrain? get() = aiBrain
|
||||
|
||||
suspend fun runAiChat(message: String): Map<String, Any?> {
|
||||
val brain = aiBrain ?: return mapOf("code" to 503, "message" to "AI Brain 未启用,请配置卡若 API Key")
|
||||
val status = DeviceInfo.collectQuick(context)
|
||||
return brain.chatAndExecute(message.trim(), status) { script, action, params ->
|
||||
skillExecutor.execute(action, params, script)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeLocal(action: String, params: Map<String, Any?>, script: String? = null): Map<String, Any?> {
|
||||
commandsExecuted += 1
|
||||
return skillExecutor.execute(action, params, script)
|
||||
}
|
||||
|
||||
fun start(
|
||||
serverUrl: String,
|
||||
projectId: String,
|
||||
deviceId: String,
|
||||
publicServers: List<String> = emptyList(),
|
||||
) {
|
||||
this.deviceId = deviceId
|
||||
this.projectId = projectId
|
||||
this.pairingToken = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
|
||||
.getString("pairing_token", "")?.trim().orEmpty()
|
||||
this.publicServers = publicServers.filter { it.isNotBlank() }
|
||||
this.serverUrl = buildWsUrl(serverUrl, deviceId)
|
||||
this.candidates = buildCandidates(this.serverUrl, this.publicServers, deviceId)
|
||||
this.candidateIdx = 0
|
||||
this.connectStage = "primary"
|
||||
startTime = System.currentTimeMillis()
|
||||
|
||||
Logger.i("AgentEngine starting: server=$serverUrl device=$deviceId project=$projectId candidates=${candidates.size}")
|
||||
|
||||
messageJob = scope.launch { collectMessages() }
|
||||
|
||||
scope.launch {
|
||||
wsManager.state.collectLatest { state ->
|
||||
Logger.i("Connection state: $state [stage=$connectStage]")
|
||||
onStateChange?.invoke(state)
|
||||
when (state) {
|
||||
ConnectionState.CONNECTED -> onConnected()
|
||||
ConnectionState.DISCONNECTED -> onDisconnected()
|
||||
ConnectionState.CONNECTING -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wsManager.connect(this.serverUrl)
|
||||
}
|
||||
|
||||
private fun buildWsUrl(base: String, deviceId: String): String {
|
||||
val parts = base.trim().split("?", limit = 2)
|
||||
val cleaned = parts[0].trimEnd('/')
|
||||
val path = if (cleaned.contains("/ws/device")) {
|
||||
if (cleaned.endsWith(deviceId)) cleaned else "$cleaned/$deviceId"
|
||||
} else {
|
||||
"$cleaned/ws/device/$deviceId"
|
||||
}
|
||||
val queryParts = mutableListOf<String>()
|
||||
if (parts.size == 2 && parts[1].isNotBlank()) queryParts.add(parts[1])
|
||||
if (pairingToken.isNotBlank() && queryParts.none { it.startsWith("token=") }) {
|
||||
queryParts.add("token=${java.net.URLEncoder.encode(pairingToken, "UTF-8")}")
|
||||
}
|
||||
return if (queryParts.isEmpty()) path else "$path?${queryParts.joinToString("&")}"
|
||||
}
|
||||
|
||||
/** BIND-03:构建有序候选 [主连接, 公网1, ...],自动补全 + 去重。 */
|
||||
private fun buildCandidates(primary: String, publics: List<String>, deviceId: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
for (u in listOf(primary) + publics) {
|
||||
val full = buildWsUrl(u.trim().trimEnd('/'), deviceId)
|
||||
if (full.isNotBlank() && full !in out) out.add(full)
|
||||
}
|
||||
return if (out.isEmpty()) listOf(primary) else out
|
||||
}
|
||||
|
||||
/**
|
||||
* BIND-05:网络可用(换 Wi-Fi / 恢复 4G)时由前台服务回调,触发重寻服。
|
||||
* 已连接则忽略;断开时优先 beacon 发现,否则轮换候选重连。
|
||||
*/
|
||||
fun onNetworkAvailable() {
|
||||
if (wsManager.isConnected || rediscovering) return
|
||||
rediscovering = true
|
||||
scope.launch {
|
||||
try {
|
||||
Logger.i("网络恢复 → 重寻服 (beacon 优先)")
|
||||
val discovered = BeaconDiscovery.discover(timeoutMs = 8_000L)
|
||||
if (discovered != null) {
|
||||
connectStage = "lan"
|
||||
serverUrl = buildWsUrl(discovered.wsUrl, deviceId)
|
||||
Logger.i("[寻服:lan] beacon 命中 → $serverUrl")
|
||||
} else {
|
||||
rotateCandidate()
|
||||
}
|
||||
wsManager.disconnect()
|
||||
delay(500)
|
||||
wsManager.connect(serverUrl)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("重寻服异常", e)
|
||||
} finally {
|
||||
rediscovering = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** BIND-03:轮换到下一个候选服务器,更新阶段标记。 */
|
||||
private fun rotateCandidate() {
|
||||
if (candidates.size <= 1) return
|
||||
candidateIdx = (candidateIdx + 1) % candidates.size
|
||||
serverUrl = candidates[candidateIdx]
|
||||
connectStage = if (candidateIdx == 0) "primary" else "public"
|
||||
Logger.i("[寻服:$connectStage] 切换候选 ${candidateIdx + 1}/${candidates.size} → $serverUrl")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
heartbeatJob?.cancel()
|
||||
messageJob?.cancel()
|
||||
aiBrainJob?.cancel()
|
||||
autonomousJob?.cancel()
|
||||
wsManager.disconnect()
|
||||
scope.cancel()
|
||||
Logger.i("AgentEngine stopped")
|
||||
}
|
||||
|
||||
fun sendEvent(event: String, data: Map<String, Any?> = emptyMap()) {
|
||||
if (!wsManager.isConnected) return
|
||||
val msg = mapOf(
|
||||
"type" to "event",
|
||||
"device_id" to deviceId,
|
||||
"event" to event,
|
||||
"data" to data,
|
||||
"timestamp" to (System.currentTimeMillis() / 1000),
|
||||
)
|
||||
wsManager.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
private fun onConnected() {
|
||||
lastPongAt = System.currentTimeMillis()
|
||||
stopAutonomousMode()
|
||||
aiBrain?.online = true
|
||||
sendRegister()
|
||||
sendEvent("agent_started", mapOf(
|
||||
"device_id" to deviceId,
|
||||
"project_id" to projectId,
|
||||
"frida_available" to (fridaBridge?.isReady ?: false),
|
||||
"ai_brain_enabled" to (aiBrain != null),
|
||||
"anti_ban" to antiBan.statusSummary(),
|
||||
))
|
||||
flushOfflineBuffer()
|
||||
startHeartbeat()
|
||||
startAiBrainLoop()
|
||||
}
|
||||
|
||||
private fun onDisconnected() {
|
||||
heartbeatJob?.cancel()
|
||||
aiBrainJob?.cancel()
|
||||
startAutonomousMode()
|
||||
}
|
||||
|
||||
private fun sendRegister() {
|
||||
val info = DeviceInfo.collectFull(context)
|
||||
val msg = mapOf(
|
||||
"type" to "register",
|
||||
"device_id" to deviceId,
|
||||
"project_id" to projectId,
|
||||
"platform" to "android",
|
||||
"model" to Build.MODEL,
|
||||
"sdk_version" to Build.VERSION.SDK_INT,
|
||||
"app_version" to VERSION,
|
||||
"agent_version" to VERSION,
|
||||
"heartbeat_interval_seconds" to (heartbeatIntervalMs / 1000),
|
||||
"device_profile" to info,
|
||||
"capabilities" to buildCapabilities(),
|
||||
"connect_stage" to connectStage, // BIND-07 寻服阶段上报
|
||||
// 配对令牌仅用于握手,禁止上报到设备资料或管理 API。
|
||||
"server_url" to serverUrl.substringBefore('?'),
|
||||
"server_candidate_count" to candidates.size,
|
||||
)
|
||||
wsManager.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
private fun buildCapabilities(): List<String> {
|
||||
val caps = mutableListOf(
|
||||
"websocket", "accessibility", "shell", "screenshot",
|
||||
"click", "swipe", "input", "ui_tree", "app_control",
|
||||
"skill_execute", "event", "device_request"
|
||||
)
|
||||
if (fridaBridge?.isReady == true) caps.addAll(listOf("frida", "hook", "frida_rpc"))
|
||||
if (aiBrain != null) caps.addAll(listOf("ai_brain", "autonomous_mode"))
|
||||
return caps
|
||||
}
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = scope.launch {
|
||||
var missedCount = 0
|
||||
while (isActive && wsManager.isConnected) {
|
||||
delay(heartbeatIntervalMs)
|
||||
if (!wsManager.isConnected) break
|
||||
|
||||
// BIND-07:心跳带寻服阶段,供中台实时显示连接来源
|
||||
val status = DeviceInfo.collectQuick(context) + ("connect_stage" to connectStage)
|
||||
val hb = mapOf(
|
||||
"type" to "heartbeat",
|
||||
"device_id" to deviceId,
|
||||
"timestamp" to (System.currentTimeMillis() / 1000),
|
||||
"uptime" to ((System.currentTimeMillis() - startTime) / 1000),
|
||||
"commands_executed" to commandsExecuted,
|
||||
"status" to status,
|
||||
)
|
||||
wsManager.send(gson.toJson(hb))
|
||||
|
||||
val elapsed = System.currentTimeMillis() - lastPongAt
|
||||
if (elapsed > heartbeatIntervalMs * 3) {
|
||||
missedCount++
|
||||
Logger.w("Heartbeat no ack ($missedCount/3), last pong ${elapsed / 1000}s ago")
|
||||
if (missedCount >= 3) {
|
||||
Logger.e("3 missed heartbeats, forcing reconnect")
|
||||
rotateCandidate() // BIND-03:连续丢心跳 → 轮换候选(含公网回退)
|
||||
wsManager.disconnect()
|
||||
delay(1000)
|
||||
wsManager.connect(serverUrl)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
missedCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAiBrainLoop() {
|
||||
if (aiBrain == null) return
|
||||
aiBrainJob?.cancel()
|
||||
aiBrainJob = scope.launch {
|
||||
while (isActive && wsManager.isConnected) {
|
||||
delay(aiBrain.brainInterval * 1000L)
|
||||
if (!wsManager.isConnected) break
|
||||
try {
|
||||
val status = DeviceInfo.collectQuick(context)
|
||||
val result = aiBrain.heartbeatCycle(status) { script, action, params ->
|
||||
skillExecutor.execute(action, params, script)
|
||||
}
|
||||
if (result["acted"] == true) {
|
||||
sendEvent("ai_brain_acted", result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("AI Brain loop error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAutonomousMode() {
|
||||
if (aiBrain == null || autonomousJob?.isActive == true) return
|
||||
aiBrain.online = false
|
||||
autonomousJob = scope.launch {
|
||||
Logger.i("Entering autonomous mode")
|
||||
while (isActive && !wsManager.isConnected) {
|
||||
try {
|
||||
val status = DeviceInfo.collectQuick(context)
|
||||
val battery = (status["battery_level"] as? Number)?.toInt() ?: 100
|
||||
if (battery < 10) {
|
||||
delay(aiBrain.brainInterval * 2000L)
|
||||
continue
|
||||
}
|
||||
aiBrain.heartbeatCycle(status) { script, action, params ->
|
||||
skillExecutor.execute(action, params, script)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Autonomous loop error", e)
|
||||
}
|
||||
delay(aiBrain.brainInterval * 1000L)
|
||||
}
|
||||
Logger.i("Exiting autonomous mode")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAutonomousMode() {
|
||||
autonomousJob?.cancel()
|
||||
autonomousJob = null
|
||||
}
|
||||
|
||||
private fun flushOfflineBuffer() {
|
||||
if (aiBrain == null) return
|
||||
val buffered = aiBrain.flushOfflineBuffer()
|
||||
if (buffered.isEmpty()) return
|
||||
Logger.i("Uploading ${buffered.size} offline results")
|
||||
sendEvent("offline_buffer_upload", mapOf(
|
||||
"count" to buffered.size,
|
||||
"results" to buffered.takeLast(50),
|
||||
))
|
||||
}
|
||||
|
||||
private suspend fun collectMessages() {
|
||||
wsManager.messages.collect { json ->
|
||||
try {
|
||||
handleMessage(json)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Message handling error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessage(json: String) {
|
||||
val msg = ProtocolHandler.parse(json)
|
||||
|
||||
when (msg) {
|
||||
is ServerMessage.Registered -> {
|
||||
lastPongAt = System.currentTimeMillis()
|
||||
Logger.i("Server confirmed registration")
|
||||
}
|
||||
|
||||
is ServerMessage.Pong -> {
|
||||
lastPongAt = System.currentTimeMillis()
|
||||
if (msg.pendingTasks > 0) Logger.d("Server has ${msg.pendingTasks} pending tasks")
|
||||
}
|
||||
|
||||
is ServerMessage.Execute -> {
|
||||
commandsExecuted++
|
||||
val result = withContext(Dispatchers.Main) {
|
||||
if (msg.script != null) {
|
||||
skillExecutor.execute(msg.action, msg.params, msg.script)
|
||||
} else {
|
||||
skillExecutor.execute(msg.action, msg.params)
|
||||
}
|
||||
}
|
||||
sendResponse(msg.commandId, result)
|
||||
}
|
||||
|
||||
is ServerMessage.HookExecute -> {
|
||||
commandsExecuted++
|
||||
val result = skillExecutor.execute(msg.action, msg.params, msg.script)
|
||||
sendResponse(msg.commandId, result)
|
||||
}
|
||||
|
||||
is ServerMessage.AiTask -> {
|
||||
if (aiBrain != null) {
|
||||
aiBrain.addTask(msg.instruction, "server", msg.priority)
|
||||
sendResponse(msg.commandId, mapOf("code" to 200, "message" to "AI任务已入队"))
|
||||
} else {
|
||||
sendResponse(msg.commandId, mapOf("code" to 503, "message" to "AI Brain未启用"))
|
||||
}
|
||||
}
|
||||
|
||||
is ServerMessage.StandingOrder -> {
|
||||
aiBrain?.addStandingOrder(msg.order)
|
||||
sendResponse(msg.commandId, mapOf("code" to 200, "message" to "常驻指令已添加"))
|
||||
}
|
||||
|
||||
is ServerMessage.ConfigUpdate -> {
|
||||
msg.heartbeatInterval?.let { sec ->
|
||||
if (sec in 5..120) {
|
||||
heartbeatIntervalMs = sec * 1000L
|
||||
startHeartbeat()
|
||||
Logger.i("Heartbeat updated to ${sec}s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ServerMessage.AgentExecute -> {
|
||||
commandsExecuted++
|
||||
val result = skillExecutor.executeAgentTask(msg.task)
|
||||
sendResponse(msg.commandId, result)
|
||||
}
|
||||
|
||||
is ServerMessage.DeviceRequestAck -> {
|
||||
val hb = (msg.data["heartbeat_interval"] as? Number)?.toInt()
|
||||
if (hb != null && hb in 5..120) {
|
||||
heartbeatIntervalMs = hb * 1000L
|
||||
startHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
is ServerMessage.UiUpdate -> {
|
||||
HotUpdateManager.applyUpdate(context, msg.data)
|
||||
sendResponse(msg.commandId, mapOf("code" to 200, "message" to "UI updated"))
|
||||
}
|
||||
|
||||
is ServerMessage.Unknown -> Logger.w("Unknown message type: ${msg.type}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendResponse(commandId: String, result: Map<String, Any?>) {
|
||||
if (!wsManager.isConnected) return
|
||||
val resp = mapOf(
|
||||
"type" to "response",
|
||||
"command_id" to commandId,
|
||||
"device_id" to deviceId,
|
||||
"code" to (result["code"] ?: 200),
|
||||
"message" to (result["message"] ?: "success"),
|
||||
"data" to (result["data"] ?: result),
|
||||
"timestamp" to (System.currentTimeMillis() / 1000),
|
||||
)
|
||||
wsManager.send(gson.toJson(resp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
/**
|
||||
* BIND-05:UDP 8898 beacon 自动发现,与设备端 Python `sdk/agent/sdk_discovery.py`
|
||||
* 及服务端 `sdk/app/services/discovery_service.py` 协议对齐。
|
||||
*
|
||||
* SDK 每 5s 向局域网广播 {magic:"WORKPHONE_SDK", port, ws_path, ips:[...]};
|
||||
* 本类监听该端口,挑选最优私网 IP(优先 192.168.110.x),返回 WS 基址。
|
||||
*
|
||||
* 与 LanDiscovery(TCP 子网扫描)互补:beacon 优先、延迟更低、命中即返回。
|
||||
*/
|
||||
object BeaconDiscovery {
|
||||
|
||||
private const val BEACON_PORT = 8898
|
||||
private const val BEACON_MAGIC = "WORKPHONE_SDK"
|
||||
|
||||
/**
|
||||
* 监听 beacon,命中即返回;超时返回 null。
|
||||
* @param timeoutMs 监听总时长(默认 20s,对齐 WP_DISCOVER_TIMEOUT)
|
||||
*/
|
||||
suspend fun discover(timeoutMs: Long = 20_000L): DiscoveredServer? = withContext(Dispatchers.IO) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
var sock: DatagramSocket? = null
|
||||
try {
|
||||
sock = DatagramSocket(null).apply {
|
||||
reuseAddress = true
|
||||
soTimeout = 1_000
|
||||
// 绑 8898 接收广播;失败则绑任意端口(部分系统受限)
|
||||
try {
|
||||
bind(InetSocketAddress(BEACON_PORT))
|
||||
} catch (_: Exception) {
|
||||
bind(InetSocketAddress(0))
|
||||
}
|
||||
}
|
||||
val buf = ByteArray(4096)
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
val pkt = DatagramPacket(buf, buf.size)
|
||||
sock.receive(pkt)
|
||||
val text = String(pkt.data, 0, pkt.length, Charsets.UTF_8)
|
||||
val parsed = parseBeacon(text)
|
||||
if (parsed != null) {
|
||||
Logger.i("BeaconDiscovery: 发现 SDK ${parsed.wsUrl}")
|
||||
return@withContext parsed
|
||||
}
|
||||
} catch (_: java.net.SocketTimeoutException) {
|
||||
// 继续等到 deadline
|
||||
} catch (e: Exception) {
|
||||
Logger.d("BeaconDiscovery recv 异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w("BeaconDiscovery 启动失败: ${e.message}")
|
||||
} finally {
|
||||
try { sock?.close() } catch (_: Exception) {}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun parseBeacon(text: String): DiscoveredServer? {
|
||||
return try {
|
||||
val obj = JSONObject(text)
|
||||
if (obj.optString("magic") != BEACON_MAGIC) return null
|
||||
val port = obj.optInt("port", 8899)
|
||||
val wsPath = obj.optString("ws_path", "/ws/device").trimEnd('/')
|
||||
val ipsArr = obj.optJSONArray("ips")
|
||||
val ips = mutableListOf<String>()
|
||||
if (ipsArr != null) {
|
||||
for (i in 0 until ipsArr.length()) ips.add(ipsArr.optString(i))
|
||||
}
|
||||
val host = pickBestIp(ips) ?: return null
|
||||
DiscoveredServer(host, port, "ws://$host:$port$wsPath", "beacon ($host:$port)")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先 192.168.110.x(工作机常用网段),否则取第一个私网 IP。 */
|
||||
private fun pickBestIp(ips: List<String>): String? {
|
||||
if (ips.isEmpty()) return null
|
||||
ips.firstOrNull { it.startsWith("192.168.110.") }?.let { return it }
|
||||
ips.firstOrNull { it.startsWith("192.168.") || it.startsWith("10.") || it.startsWith("172.") }?.let { return it }
|
||||
return ips.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import android.content.Context
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
|
||||
object HotUpdateManager {
|
||||
|
||||
private val listeners = mutableSetOf<() -> Unit>()
|
||||
private val http = OkHttpClient()
|
||||
|
||||
fun addRefreshListener(listener: () -> Unit) {
|
||||
synchronized(listeners) { listeners.add(listener) }
|
||||
}
|
||||
|
||||
fun removeRefreshListener(listener: () -> Unit) {
|
||||
synchronized(listeners) { listeners.remove(listener) }
|
||||
}
|
||||
|
||||
fun notifyRefresh() {
|
||||
synchronized(listeners) { listeners.toList() }.forEach {
|
||||
try { it() } catch (e: Exception) { Logger.e("HotUpdate listener error", e) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun applyUpdate(context: Context, data: Map<String, Any?>) {
|
||||
val type = data["update_type"] as? String ?: "refresh"
|
||||
|
||||
when (type) {
|
||||
"hub_html" -> {
|
||||
val url = data["url"] as? String
|
||||
val content = data["content"] as? String
|
||||
if (content != null) {
|
||||
saveHubHtml(context, content)
|
||||
} else if (url != null) {
|
||||
downloadAndSaveHub(context, url)
|
||||
}
|
||||
notifyRefresh()
|
||||
}
|
||||
"config" -> {
|
||||
val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE).edit()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(data["config"] as? Map<String, Any?>)?.forEach { (k, v) ->
|
||||
when (v) {
|
||||
is String -> prefs.putString(k, v)
|
||||
is Boolean -> prefs.putBoolean(k, v)
|
||||
is Number -> prefs.putLong(k, v.toLong())
|
||||
}
|
||||
}
|
||||
prefs.apply()
|
||||
notifyRefresh()
|
||||
}
|
||||
"refresh" -> {
|
||||
notifyRefresh()
|
||||
}
|
||||
}
|
||||
Logger.i("HotUpdate applied: type=$type")
|
||||
}
|
||||
|
||||
private fun saveHubHtml(context: Context, content: String) {
|
||||
val file = File(context.filesDir, "hub_override.html")
|
||||
file.writeText(content)
|
||||
Logger.i("Hub HTML saved (${content.length} chars)")
|
||||
}
|
||||
|
||||
private suspend fun downloadAndSaveHub(context: Context, url: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val req = Request.Builder().url(url).build()
|
||||
val body = http.newCall(req).execute().body?.string()
|
||||
if (!body.isNullOrEmpty()) {
|
||||
saveHubHtml(context, body)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Hub download failed: $url", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getHubOverridePath(context: Context): File? {
|
||||
val file = File(context.filesDir, "hub_override.html")
|
||||
return if (file.exists() && file.length() > 0) file else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class DiscoveredServer(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val wsUrl: String,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
object LanDiscovery {
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(2, TimeUnit.SECONDS)
|
||||
.readTimeout(2, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val commonPorts = listOf(8899, 8080, 8443, 3000, 9000, 8900, 18899)
|
||||
|
||||
suspend fun discover(localIp: String? = null): List<DiscoveredServer> = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<DiscoveredServer>()
|
||||
val subnet = extractSubnet(localIp)
|
||||
|
||||
Logger.i("LanDiscovery: scanning subnet $subnet.0/24")
|
||||
|
||||
val jobs = (1..254).map { host ->
|
||||
async {
|
||||
val ip = "$subnet.$host"
|
||||
scanHost(ip)
|
||||
}
|
||||
}
|
||||
|
||||
val timeout = withTimeoutOrNull(12_000) {
|
||||
jobs.awaitAll().filterNotNull().forEach { results.add(it) }
|
||||
}
|
||||
if (timeout == null) {
|
||||
jobs.filter { it.isCompleted }.mapNotNull { it.getCompleted() }.forEach {
|
||||
if (it !in results) results.add(it)
|
||||
}
|
||||
jobs.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
Logger.i("LanDiscovery: found ${results.size} servers")
|
||||
results
|
||||
}
|
||||
|
||||
private fun extractSubnet(localIp: String?): String {
|
||||
if (localIp != null) {
|
||||
val parts = localIp.split(".")
|
||||
if (parts.size == 4) return "${parts[0]}.${parts[1]}.${parts[2]}"
|
||||
}
|
||||
return "192.168.1"
|
||||
}
|
||||
|
||||
private fun scanHost(ip: String): DiscoveredServer? {
|
||||
for (port in commonPorts) {
|
||||
try {
|
||||
val sock = Socket()
|
||||
sock.connect(InetSocketAddress(ip, port), 800)
|
||||
sock.close()
|
||||
|
||||
val wsUrl = "ws://$ip:$port/ws/device"
|
||||
val httpUrl = "http://$ip:$port/health"
|
||||
|
||||
try {
|
||||
val req = Request.Builder().url(httpUrl).build()
|
||||
val resp = http.newCall(req).execute()
|
||||
val code = resp.code
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
if (code in 200..299 || body.contains("ok", ignoreCase = true) || body.contains("workphone", ignoreCase = true)) {
|
||||
return DiscoveredServer(ip, port, wsUrl, "工作手机服务 ($ip:$port)")
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
try {
|
||||
val apiUrl = "http://$ip:$port/api/status"
|
||||
val req2 = Request.Builder().url(apiUrl).build()
|
||||
val resp2 = http.newCall(req2).execute()
|
||||
val code2 = resp2.code
|
||||
resp2.close()
|
||||
if (code2 in 200..404) {
|
||||
return DiscoveredServer(ip, port, wsUrl, "服务器 ($ip:$port)")
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
if (port == 8899 || port == 18899) {
|
||||
return DiscoveredServer(ip, port, wsUrl, "可能的服务 ($ip:$port)")
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
sealed class ServerMessage {
|
||||
data class Registered(val raw: Map<String, Any?>) : ServerMessage()
|
||||
data class Pong(val pendingTasks: Int) : ServerMessage()
|
||||
data class Execute(val commandId: String, val action: String, val params: Map<String, Any?>, val script: String?, val channel: String) : ServerMessage()
|
||||
data class HookExecute(val commandId: String, val script: String, val action: String, val params: Map<String, Any?>) : ServerMessage()
|
||||
data class AiTask(val commandId: String, val instruction: String, val priority: Int) : ServerMessage()
|
||||
data class StandingOrder(val commandId: String, val order: String) : ServerMessage()
|
||||
data class ConfigUpdate(val heartbeatInterval: Int?, val raw: Map<String, Any?>) : ServerMessage()
|
||||
data class AgentExecute(val commandId: String, val task: String) : ServerMessage()
|
||||
data class DeviceRequestAck(val requestId: String, val data: Map<String, Any?>) : ServerMessage()
|
||||
data class UiUpdate(val commandId: String, val data: Map<String, Any?>) : ServerMessage()
|
||||
data class Unknown(val type: String?, val raw: Map<String, Any?>) : ServerMessage()
|
||||
}
|
||||
|
||||
object ProtocolHandler {
|
||||
|
||||
private val gson = Gson()
|
||||
private val mapType = object : TypeToken<Map<String, Any?>>() {}.type
|
||||
|
||||
fun parse(json: String): ServerMessage {
|
||||
val data: Map<String, Any?> = gson.fromJson(json, mapType)
|
||||
val type = data["type"] as? String
|
||||
val commandId = data["command_id"] as? String ?: ""
|
||||
|
||||
return when (type) {
|
||||
"registered" -> ServerMessage.Registered(data)
|
||||
|
||||
"pong", "heartbeat_ack" -> {
|
||||
val pending = (data["pending_tasks"] as? Number)?.toInt() ?: 0
|
||||
ServerMessage.Pong(pending)
|
||||
}
|
||||
|
||||
"execute" -> {
|
||||
val d = asMap(data["data"]) ?: data
|
||||
ServerMessage.Execute(
|
||||
commandId = commandId,
|
||||
action = d["action"] as? String ?: "",
|
||||
params = asMap(d["params"]) ?: emptyMap(),
|
||||
script = d["script"] as? String,
|
||||
channel = d["channel"] as? String ?: "auto"
|
||||
)
|
||||
}
|
||||
|
||||
"hook_execute" -> {
|
||||
val d = asMap(data["data"]) ?: data
|
||||
ServerMessage.HookExecute(
|
||||
commandId = commandId,
|
||||
script = d["script"] as? String ?: "",
|
||||
action = d["action"] as? String ?: "",
|
||||
params = asMap(d["params"]) ?: emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
"ai_task" -> {
|
||||
val d = asMap(data["data"]) ?: data
|
||||
ServerMessage.AiTask(
|
||||
commandId = commandId,
|
||||
instruction = d["instruction"] as? String ?: "",
|
||||
priority = (d["priority"] as? Number)?.toInt() ?: 5
|
||||
)
|
||||
}
|
||||
|
||||
"standing_order" -> {
|
||||
val d = asMap(data["data"]) ?: data
|
||||
ServerMessage.StandingOrder(commandId, d["order"] as? String ?: "")
|
||||
}
|
||||
|
||||
"config", "config_update" -> {
|
||||
val d = asMap(data["data"]) ?: asMap(data["params"]) ?: data
|
||||
val hb = (d["heartbeat_interval_seconds"] as? Number)?.toInt()
|
||||
?: (d["heartbeat_interval"] as? Number)?.toInt()
|
||||
ServerMessage.ConfigUpdate(hb, d)
|
||||
}
|
||||
|
||||
"agent_execute" -> {
|
||||
val d = asMap(data["data"]) ?: data
|
||||
ServerMessage.AgentExecute(commandId, d["task"] as? String ?: "")
|
||||
}
|
||||
|
||||
"device_request_ack" -> {
|
||||
ServerMessage.DeviceRequestAck(
|
||||
data["request_id"] as? String ?: "",
|
||||
asMap(data["data"]) ?: emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
"ui_update", "hot_update" -> {
|
||||
ServerMessage.UiUpdate(commandId, asMap(data["data"]) ?: data)
|
||||
}
|
||||
|
||||
else -> ServerMessage.Unknown(type, data)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun asMap(v: Any?): Map<String, Any?>? = v as? Map<String, Any?>
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import com.system.cloudservice.antiban.AntiBanManager
|
||||
import com.system.cloudservice.frida.FridaBridge
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import java.io.File
|
||||
|
||||
class SkillExecutor(
|
||||
private val context: Context,
|
||||
private val fridaBridge: FridaBridge?,
|
||||
private val antiBan: AntiBanManager,
|
||||
) {
|
||||
suspend fun execute(
|
||||
action: String,
|
||||
params: Map<String, Any?>,
|
||||
script: String? = null,
|
||||
): Map<String, Any?> {
|
||||
// Pre-action risk check
|
||||
if (!antiBan.canOperate(action)) {
|
||||
val status = antiBan.riskStatus()
|
||||
return mapOf("code" to 429, "message" to "风控暂停中", "data" to status)
|
||||
}
|
||||
|
||||
// wechat/douyin 等业务脚本走 Hook 优先链;android/system/device 是设备控制域,
|
||||
// 不能误当成 Frida 脚本,否则公网 Fleet 的 app_start/ui_tree 会落到 Shell不支持。
|
||||
val deviceControlScripts = setOf("android", "system", "device")
|
||||
if (script != null && script.lowercase() !in deviceControlScripts) {
|
||||
return executeWithFridaPriority(script, action, params)
|
||||
}
|
||||
|
||||
return try {
|
||||
when (action) {
|
||||
"open_app", "app_start", "start_app" -> openApp(params["package"] as? String ?: "")
|
||||
"app_stop", "stop_app" -> doShell(
|
||||
mapOf("command" to "am force-stop ${params["package"] ?: ""}")
|
||||
)
|
||||
"get_installed_apps" -> getInstalledApps()
|
||||
"get_device_info" -> mapOf("code" to 200, "data" to DeviceInfo.collectFull(context))
|
||||
"click", "tap" -> doClick(params)
|
||||
"swipe" -> doSwipe(params)
|
||||
"input_text", "input" -> doInputText(params)
|
||||
"key_event", "press_key" -> doKeyEvent(params)
|
||||
"screenshot" -> doScreenshot(params)
|
||||
"back" -> doGlobalAction("back")
|
||||
"home" -> doGlobalAction("home")
|
||||
"recent" -> doGlobalAction("recent")
|
||||
"dump_ui", "ui_tree" -> doDumpUi()
|
||||
"shell", "run_shell" -> doShell(params)
|
||||
"get_foreground" -> doShell(mapOf("command" to "dumpsys window | grep mCurrentFocus"))
|
||||
"install_apk" -> doShell(mapOf("command" to "pm install -r -g ${params["path"]}"))
|
||||
"voice_command" -> mapOf("code" to 200, "message" to "语音命令已接收: ${params["text"]}")
|
||||
"status" -> mapOf("code" to 200, "data" to DeviceInfo.collectQuick(context))
|
||||
else -> mapOf("code" to 400, "message" to "未知命令: $action")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("SkillExecutor error: $action", e)
|
||||
mapOf<String, Any?>("code" to 500, "message" to (e.message ?: "执行失败"))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeAgentTask(task: String): Map<String, Any?> {
|
||||
if (task.isBlank()) return mapOf<String, Any?>("code" to 400, "message" to "task为空")
|
||||
Logger.i("Agent task: $task")
|
||||
|
||||
return try {
|
||||
val result = when {
|
||||
task.contains("微信") -> executeWithFridaPriority("wechat", "agent_task", mapOf("task" to task))
|
||||
task.contains("抖音") -> executeWithFridaPriority("douyin", "agent_task", mapOf("task" to task))
|
||||
task.contains("小红书") -> executeWithFridaPriority("xhs", "agent_task", mapOf("task" to task))
|
||||
else -> {
|
||||
val shellResult = ShellExecutor.execute(task)
|
||||
mapOf("code" to if (shellResult.success) 200 else 500, "data" to mapOf("output" to shellResult.output))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Agent task error", e)
|
||||
mapOf<String, Any?>("code" to 500, "message" to (e.message ?: "执行失败"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeWithFridaPriority(
|
||||
script: String, action: String, params: Map<String, Any?>
|
||||
): Map<String, Any?> {
|
||||
// frida-server 端口可用不等于已经附着目标进程。状态接口必须分开上报,
|
||||
// 避免把“脚本已装载”误报成“微信 Hook 成功”。
|
||||
if (script == "wechat" && action == "get_hook_status") {
|
||||
val status = fridaBridge?.getStatus().orEmpty()
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"data" to mapOf(
|
||||
"success" to true,
|
||||
"hooked" to false,
|
||||
"frida_server_ready" to (status["ready"] == true),
|
||||
"root_available" to (status["root_available"] == true),
|
||||
"channel" to if (status["ready"] == true) "frida_server" else "no_root_a11y",
|
||||
"note" to if (status["ready"] == true)
|
||||
"Frida 服务已就绪,微信进程尚未确认注入"
|
||||
else "Frida 未就绪,使用无障碍自动化",
|
||||
),
|
||||
"channel" to if (status["ready"] == true) "frida_server" else "no_root_a11y",
|
||||
)
|
||||
}
|
||||
|
||||
// Channel 1: Frida Hook (priority)
|
||||
if (fridaBridge?.isReady == true) {
|
||||
try {
|
||||
val result = fridaBridge.execute(script, action, params)
|
||||
if (result["success"] == true) {
|
||||
return mapOf("code" to 200, "data" to result, "channel" to "frida")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w("Frida execute failed, falling back to A11y: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Channel 1b: 无 Root 微信(无障碍 + 包信息),与 Frida Hook 能力不对等但可闭环
|
||||
tryWechatNoRoot(script, action, params)?.let { return it }
|
||||
|
||||
// Channel 2: Accessibility Service
|
||||
val a11y = AgentAccessibilityService.getInstance()
|
||||
if (a11y != null) {
|
||||
return executeViaAccessibility(a11y, action, params)
|
||||
}
|
||||
|
||||
// Channel 3: Shell fallback
|
||||
return executeViaShell(action, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 无 Root / Frida 未就绪时,微信脚本走无障碍与包管理器。
|
||||
* 返回 null 表示交给后续通用 A11y/Shell。
|
||||
*/
|
||||
private suspend fun tryWechatNoRoot(
|
||||
script: String,
|
||||
action: String,
|
||||
params: Map<String, Any?>,
|
||||
): Map<String, Any?>? {
|
||||
if (script != "wechat") return null
|
||||
|
||||
when (action) {
|
||||
"ping" -> {
|
||||
val ver = WechatNoRootAutomation.getWechatVersion(context)
|
||||
val quick = DeviceInfo.collectQuick(context)
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"message" to "pong",
|
||||
"data" to mapOf(
|
||||
"success" to true,
|
||||
"installed" to (ver["success"] == true),
|
||||
"wechat_version" to ver["version"],
|
||||
"wechat_running" to (quick["wechat_running"] == true),
|
||||
"channel" to "no_root_pkg",
|
||||
),
|
||||
"channel" to "no_root_pkg",
|
||||
)
|
||||
}
|
||||
"get_wechat_version" -> {
|
||||
val data = WechatNoRootAutomation.getWechatVersion(context)
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"data" to data,
|
||||
"channel" to "no_root_pkg",
|
||||
)
|
||||
}
|
||||
"get_hook_status" -> {
|
||||
val data = WechatNoRootAutomation.hookStatusStub()
|
||||
return mapOf("code" to 200, "data" to data, "channel" to "no_root_a11y")
|
||||
}
|
||||
"get_process_info" -> {
|
||||
val ver = WechatNoRootAutomation.getWechatVersion(context)
|
||||
val quick = DeviceInfo.collectQuick(context)
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"data" to mapOf(
|
||||
"success" to true,
|
||||
"wechat" to ver,
|
||||
"device_quick" to quick,
|
||||
"channel" to "no_root_pkg",
|
||||
),
|
||||
"channel" to "no_root_pkg",
|
||||
)
|
||||
}
|
||||
"check_login_state" -> {
|
||||
val ver = WechatNoRootAutomation.getWechatVersion(context)
|
||||
val running = DeviceInfo.collectQuick(context)["wechat_running"] == true
|
||||
val installed = ver["success"] == true
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"data" to mapOf(
|
||||
"success" to true,
|
||||
"installed" to installed,
|
||||
"wechat_running" to running,
|
||||
"note" to "无 Root 无法读登录态数据库,仅启发式",
|
||||
"channel" to "no_root_pkg",
|
||||
),
|
||||
"channel" to "no_root_pkg",
|
||||
)
|
||||
}
|
||||
"get_profile" -> {
|
||||
// 未注入时返回设备端已验证缓存资料,明确标注来源;不能把真 Root 设备误报为无 Root。
|
||||
val quick = DeviceInfo.collectQuick(context)
|
||||
val wxid = quick["wxid"]?.toString().orEmpty()
|
||||
return mapOf(
|
||||
"code" to if (wxid.isNotEmpty()) 200 else 501,
|
||||
"message" to if (wxid.isNotEmpty()) "success" else "微信资料缓存尚未生成",
|
||||
"data" to mapOf(
|
||||
"success" to wxid.isNotEmpty(),
|
||||
"wxid" to wxid,
|
||||
"wechat_version" to quick["wechat_version"],
|
||||
"wechat_running" to quick["wechat_running"],
|
||||
"source" to "device_verified_cache",
|
||||
"channel" to "root_cache",
|
||||
),
|
||||
"channel" to "root_cache",
|
||||
)
|
||||
}
|
||||
"get_contacts", "get_messages" -> {
|
||||
return mapOf(
|
||||
"code" to 501,
|
||||
"message" to "无 Root 未注入微信进程,无法读联系人/消息/资料库;请开启 Root+Frida 或使用 send_message(无障碍)",
|
||||
"data" to mapOf(
|
||||
"success" to false,
|
||||
"error" to "not_supported_no_root",
|
||||
"channel" to "no_root_a11y",
|
||||
),
|
||||
"channel" to "no_root_a11y",
|
||||
)
|
||||
}
|
||||
"send_message" -> {
|
||||
val a11y = AgentAccessibilityService.getInstance()
|
||||
?: return mapOf(
|
||||
"code" to 503,
|
||||
"message" to "请先开启本应用的无障碍服务",
|
||||
"data" to mapOf(
|
||||
"success" to false,
|
||||
"error" to "accessibility_disabled",
|
||||
"channel" to "no_root_a11y",
|
||||
),
|
||||
"channel" to "no_root_a11y",
|
||||
)
|
||||
val toId = (params["to_id"] as? String)?.trim().orEmpty()
|
||||
.ifEmpty { (params["to"] as? String)?.trim().orEmpty() }
|
||||
val content = (params["content"] as? String).orEmpty()
|
||||
val data = WechatNoRootAutomation.sendTextMessage(context, a11y, toId, content)
|
||||
val ok = data["success"] == true
|
||||
return mapOf(
|
||||
"code" to if (ok) 200 else 500,
|
||||
"data" to data,
|
||||
"channel" to (data["channel"]?.toString() ?: "no_root_a11y"),
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun executeViaAccessibility(
|
||||
a11y: AgentAccessibilityService, action: String, params: Map<String, Any?>
|
||||
): Map<String, Any?> {
|
||||
return when (action) {
|
||||
"click", "tap" -> {
|
||||
val x = (params["x"] as? Number)?.toInt() ?: 0
|
||||
val y = (params["y"] as? Number)?.toInt() ?: 0
|
||||
a11y.click(x, y)
|
||||
mapOf("code" to 200, "message" to "已点击 ($x, $y)", "channel" to "a11y")
|
||||
}
|
||||
"swipe" -> {
|
||||
val x1 = (params["x1"] as? Number)?.toInt() ?: 0
|
||||
val y1 = (params["y1"] as? Number)?.toInt() ?: 0
|
||||
val x2 = (params["x2"] as? Number)?.toInt() ?: 0
|
||||
val y2 = (params["y2"] as? Number)?.toInt() ?: 0
|
||||
val dur = (params["duration"] as? Number)?.toLong() ?: 300L
|
||||
a11y.swipe(x1, y1, x2, y2, dur)
|
||||
mapOf("code" to 200, "message" to "已滑动", "channel" to "a11y")
|
||||
}
|
||||
"input_text", "input" -> {
|
||||
val text = params["text"] as? String ?: ""
|
||||
a11y.inputText(text)
|
||||
mapOf("code" to 200, "message" to "已输入", "channel" to "a11y")
|
||||
}
|
||||
"back" -> { a11y.back(); mapOf("code" to 200, "message" to "已返回", "channel" to "a11y") }
|
||||
"home" -> { a11y.home(); mapOf("code" to 200, "message" to "已回到桌面", "channel" to "a11y") }
|
||||
else -> executeViaShell(action, params)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeViaShell(action: String, params: Map<String, Any?>): Map<String, Any?> {
|
||||
val cmd = when (action) {
|
||||
"click", "tap" -> "input tap ${params["x"]} ${params["y"]}"
|
||||
"swipe" -> "input swipe ${params["x1"]} ${params["y1"]} ${params["x2"]} ${params["y2"]} ${params["duration"] ?: 300}"
|
||||
"input_text", "input" -> "am broadcast -a ADB_INPUT_TEXT --es msg '${params["text"]}'"
|
||||
"back" -> "input keyevent KEYCODE_BACK"
|
||||
"home" -> "input keyevent KEYCODE_HOME"
|
||||
"screenshot" -> "screencap -p ${params["path"] ?: "/sdcard/screenshot.png"}"
|
||||
"key_event" -> "input keyevent ${params["keycode"]}"
|
||||
else -> return mapOf("code" to 400, "message" to "Shell不支持: $action")
|
||||
}
|
||||
val r = ShellExecutor.execute(cmd)
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output, "channel" to "shell")
|
||||
}
|
||||
|
||||
// ── Basic actions ──
|
||||
|
||||
private fun doClick(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val x = (params["x"] as? Number)?.toInt() ?: 0
|
||||
val y = (params["y"] as? Number)?.toInt() ?: 0
|
||||
val a11y = AgentAccessibilityService.getInstance()
|
||||
if (a11y != null) {
|
||||
a11y.click(x, y)
|
||||
return mapOf("code" to 200, "message" to "已点击 ($x, $y)")
|
||||
}
|
||||
val r = ShellExecutor.execute("input tap $x $y")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
}
|
||||
|
||||
private fun doSwipe(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val x1 = (params["x1"] as? Number)?.toInt() ?: 0
|
||||
val y1 = (params["y1"] as? Number)?.toInt() ?: 0
|
||||
val x2 = (params["x2"] as? Number)?.toInt() ?: 0
|
||||
val y2 = (params["y2"] as? Number)?.toInt() ?: 0
|
||||
val dur = (params["duration"] as? Number)?.toInt() ?: 300
|
||||
val a11y = AgentAccessibilityService.getInstance()
|
||||
if (a11y != null) {
|
||||
a11y.swipe(x1, y1, x2, y2, dur.toLong())
|
||||
return mapOf("code" to 200, "message" to "已滑动")
|
||||
}
|
||||
val r = ShellExecutor.execute("input swipe $x1 $y1 $x2 $y2 $dur")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
}
|
||||
|
||||
private fun doInputText(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val text = params["text"] as? String ?: ""
|
||||
val a11y = AgentAccessibilityService.getInstance()
|
||||
if (a11y != null) {
|
||||
a11y.inputText(text)
|
||||
return mapOf("code" to 200, "message" to "已输入文字")
|
||||
}
|
||||
val r = ShellExecutor.execute("am broadcast -a ADB_INPUT_TEXT --es msg '$text'")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
}
|
||||
|
||||
private fun doKeyEvent(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val keycode = params["keycode"] as? String ?: ""
|
||||
val r = ShellExecutor.execute("input keyevent $keycode")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
}
|
||||
|
||||
private fun doScreenshot(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val path = params["path"] as? String
|
||||
?: File(context.cacheDir, "workphone_screenshot.png").absolutePath
|
||||
val r = ShellExecutor.execute("screencap -p '$path' && chmod 644 '$path'")
|
||||
if (!r.success) {
|
||||
return mapOf("code" to 500, "message" to r.output)
|
||||
}
|
||||
return try {
|
||||
val bytes = File(path).readBytes()
|
||||
mapOf(
|
||||
"code" to 200,
|
||||
"message" to "ok",
|
||||
"data" to mapOf(
|
||||
"image_base64" to Base64.encodeToString(bytes, Base64.NO_WRAP),
|
||||
"mime_type" to "image/png",
|
||||
"size" to bytes.size,
|
||||
"path" to path,
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
mapOf("code" to 500, "message" to "截图读取失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun doGlobalAction(action: String): Map<String, Any?> {
|
||||
val a11y = AgentAccessibilityService.getInstance()
|
||||
if (a11y != null) {
|
||||
when (action) {
|
||||
"back" -> a11y.back()
|
||||
"home" -> a11y.home()
|
||||
"recent" -> a11y.recent()
|
||||
}
|
||||
return mapOf("code" to 200, "message" to "已执行: $action")
|
||||
}
|
||||
val keycode = when (action) {
|
||||
"back" -> "KEYCODE_BACK"
|
||||
"home" -> "KEYCODE_HOME"
|
||||
"recent" -> "KEYCODE_APP_SWITCH"
|
||||
else -> return mapOf("code" to 400, "message" to "unknown")
|
||||
}
|
||||
val r = ShellExecutor.execute("input keyevent $keycode")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
}
|
||||
|
||||
private fun doDumpUi(): Map<String, Any?> {
|
||||
val r = ShellExecutor.execute("uiautomator dump /sdcard/ui.xml && cat /sdcard/ui.xml")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "data" to mapOf("xml" to r.output))
|
||||
}
|
||||
|
||||
private fun doShell(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val cmd = params["command"] as? String ?: return mapOf("code" to 400, "message" to "command参数为空")
|
||||
val r = ShellExecutor.execute(cmd)
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
}
|
||||
|
||||
private fun openApp(packageName: String): Map<String, Any?> {
|
||||
return try {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
mapOf("code" to 200, "message" to "已打开 $packageName")
|
||||
} else {
|
||||
mapOf("code" to 404, "message" to "未找到应用: $packageName")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
mapOf<String, Any?>("code" to 500, "message" to (e.message ?: "打开失败"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInstalledApps(): Map<String, Any?> {
|
||||
val apps = context.packageManager.getInstalledApplications(0)
|
||||
.filter { context.packageManager.getLaunchIntentForPackage(it.packageName) != null }
|
||||
.map { it.packageName }
|
||||
return mapOf("code" to 200, "data" to apps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
|
||||
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED }
|
||||
|
||||
class WebSocketManager(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private const val MIN_RECONNECT_MS = 2_000L
|
||||
private const val MAX_RECONNECT_MS = 30_000L
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MINUTES)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private var ws: WebSocket? = null
|
||||
private var reconnectAttempts = 0
|
||||
private var reconnectJob: Job? = null
|
||||
private var manualClose = false
|
||||
private var serverUrl: String = ""
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val state: StateFlow<ConnectionState> = _state
|
||||
|
||||
private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val messages: SharedFlow<String> = _messages
|
||||
|
||||
val isConnected get() = _state.value == ConnectionState.CONNECTED
|
||||
|
||||
fun connect(url: String) {
|
||||
serverUrl = url
|
||||
manualClose = false
|
||||
reconnectAttempts = 0
|
||||
doConnect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
manualClose = true
|
||||
reconnectJob?.cancel()
|
||||
ws?.close(1000, "client_close")
|
||||
ws = null
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
fun send(json: String): Boolean {
|
||||
return ws?.send(json) ?: false
|
||||
}
|
||||
|
||||
private fun doConnect() {
|
||||
if (_state.value == ConnectionState.CONNECTING) return
|
||||
_state.value = ConnectionState.CONNECTING
|
||||
Logger.d("WS connecting: ${serverUrl.substringBefore('?')}")
|
||||
|
||||
val request = Request.Builder().url(serverUrl).build()
|
||||
ws = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Logger.i("WS connected")
|
||||
reconnectAttempts = 0
|
||||
_state.value = ConnectionState.CONNECTED
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch { _messages.emit(text) }
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Logger.w("WS closed: $code $reason")
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Logger.e("WS failure: ${t.message}")
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (manualClose) return
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
reconnectAttempts++
|
||||
val base = MIN_RECONNECT_MS * (1L shl min(reconnectAttempts, 4))
|
||||
val delay = min(base, MAX_RECONNECT_MS) + Random.nextLong(0, base / 5 + 1)
|
||||
Logger.d("WS reconnect #$reconnectAttempts in ${delay}ms")
|
||||
delay(delay)
|
||||
if (!manualClose && _state.value == ConnectionState.DISCONNECTED) {
|
||||
doConnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.system.cloudservice.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Build
|
||||
import android.content.pm.PackageManager
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* 无 Root、无 Frida 时的微信能力:无障碍 UI 自动化 + PackageManager 读版本。
|
||||
*
|
||||
* 说明:无法做进程内 Hook(JS RPC);与 Hawk Hook 脚本能力不对等,但满足「免 Root 可用」闭环。
|
||||
*/
|
||||
object WechatNoRootAutomation {
|
||||
|
||||
private const val WX_PKG = "com.tencent.mm"
|
||||
|
||||
fun getWechatVersion(context: Context): Map<String, Any?> {
|
||||
return try {
|
||||
val pm = context.packageManager
|
||||
val ver = if (Build.VERSION.SDK_INT >= 33) {
|
||||
pm.getPackageInfo(WX_PKG, PackageManager.PackageInfoFlags.of(0)).versionName
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(WX_PKG, 0).versionName
|
||||
}
|
||||
mapOf(
|
||||
"success" to true,
|
||||
"version" to (ver ?: ""),
|
||||
"channel" to "no_root_pkg",
|
||||
"note" to "PackageManager 读取,无需 Root",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
mapOf(
|
||||
"success" to false,
|
||||
"error" to (e.message ?: "微信未安装"),
|
||||
"channel" to "no_root_pkg",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hookStatusStub(): Map<String, Any?> = mapOf(
|
||||
"success" to true,
|
||||
"hooked" to false,
|
||||
"channel" to "no_root_a11y",
|
||||
"note" to "无 Root:未注入微信进程,使用无障碍自动化(非 JS Hook)",
|
||||
)
|
||||
|
||||
suspend fun openWeChat(context: Context) {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(WX_PKG)
|
||||
if (intent != null) {
|
||||
intent.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信隐藏输入节点时:聚焦输入区 → 系统剪贴板 → Root KEYCODE_PASTE,支持完整中文。 */
|
||||
private suspend fun pasteChatText(
|
||||
context: Context,
|
||||
a11y: AgentAccessibilityService,
|
||||
text: String,
|
||||
): Boolean {
|
||||
val dm = context.resources.displayMetrics
|
||||
a11y.click((dm.widthPixels * 0.46f).toInt(), (dm.heightPixels * 0.99f).toInt())
|
||||
delay(250)
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("workphone_message", text))
|
||||
delay(150)
|
||||
return ShellExecutor.execute("input keyevent 279").success // KEYCODE_PASTE
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息:首页 → 搜索 → 选人/群 → 输入框 → 发送。
|
||||
* 需用户开启无障碍,且微信语言为简体中文时命中「搜索」「发送」文案较稳。
|
||||
*/
|
||||
suspend fun sendTextMessage(
|
||||
context: Context,
|
||||
a11y: AgentAccessibilityService,
|
||||
toId: String,
|
||||
content: String,
|
||||
): Map<String, Any?> {
|
||||
if (toId.isBlank() || content.isBlank()) {
|
||||
return mapOf(
|
||||
"success" to false,
|
||||
"error" to "缺少 to_id 或 content",
|
||||
"channel" to "no_root_a11y",
|
||||
)
|
||||
}
|
||||
openWeChat(context)
|
||||
delay(2200)
|
||||
// 无论微信上次停在哪个 Tab,先回到底部“微信”会话列表。
|
||||
run {
|
||||
val dm = context.resources.displayMetrics
|
||||
a11y.click((dm.widthPixels * 0.11f).toInt(), (dm.heightPixels * 0.99f).toInt())
|
||||
}
|
||||
delay(700)
|
||||
|
||||
// 文件传输助手通常固定在微信首页,直接进入比全局搜索 filehelper 更可靠;
|
||||
// 全局搜索会把它当普通网络关键词,无法命中本地会话。
|
||||
if (toId.equals("filehelper", ignoreCase = true)) {
|
||||
val dm = context.resources.displayMetrics
|
||||
if (!a11y.clickFirstMatchingText("文件传输助手")) {
|
||||
a11y.click((dm.widthPixels * 0.36f).toInt(), (dm.heightPixels * 0.295f).toInt())
|
||||
}
|
||||
delay(1600)
|
||||
val pasted = pasteChatText(context, a11y, content)
|
||||
delay(600)
|
||||
var sent = a11y.clickFirstMatchingText("发送") || a11y.clickFirstMatchingText("Send")
|
||||
if (!sent && pasted) {
|
||||
val dm = context.resources.displayMetrics
|
||||
// 键盘弹出后输入栏上移,发送按钮约位于可用高度 62% 处。
|
||||
a11y.click((dm.widthPixels * 0.92f).toInt(), (dm.heightPixels * 0.62f).toInt())
|
||||
sent = true
|
||||
}
|
||||
return if (sent) {
|
||||
mapOf(
|
||||
"success" to true,
|
||||
"message_id" to "a11y_${System.currentTimeMillis()}",
|
||||
"channel" to "no_root_a11y",
|
||||
"attempted" to true,
|
||||
// 无障碍通道只能确认点击,微信仍可能因账号风控拒绝实际送达。
|
||||
"verified" to false,
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
"success" to false,
|
||||
"error" to "已进入文件传输助手,但未点到发送按钮",
|
||||
"channel" to "no_root_a11y",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val searchKeys = listOf("搜索", "Search")
|
||||
var openedSearch = false
|
||||
for (key in searchKeys) {
|
||||
if (a11y.clickFirstMatchingText(key)) {
|
||||
openedSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// 微信 8.0.69 首页的放大镜仅绘制图标,部分 ROM 不向无障碍树暴露文案。
|
||||
// 按屏幕比例点击右上角搜索,兼容 1080x2400 等不同分辨率。
|
||||
if (!openedSearch) {
|
||||
val dm = context.resources.displayMetrics
|
||||
a11y.click((dm.widthPixels * 0.83f).toInt(), (dm.heightPixels * 0.065f).toInt())
|
||||
openedSearch = true
|
||||
}
|
||||
if (!openedSearch) {
|
||||
Logger.w("WechatNoRoot: 未点到搜索,请确保在微信首页且无障碍已授权")
|
||||
return mapOf(
|
||||
"success" to false,
|
||||
"error" to "未找到「搜索」入口(请在微信首页重试或检查无障碍)",
|
||||
"channel" to "no_root_a11y",
|
||||
)
|
||||
}
|
||||
delay(700)
|
||||
a11y.inputText(toId)
|
||||
delay(1400)
|
||||
val displayTarget = if (toId.equals("filehelper", ignoreCase = true)) "文件传输助手" else toId
|
||||
if (!a11y.clickFirstMatchingText(displayTarget)) {
|
||||
delay(300)
|
||||
a11y.clickFirstMatchingText(displayTarget)
|
||||
}
|
||||
delay(1600)
|
||||
val pasted = pasteChatText(context, a11y, content)
|
||||
delay(500)
|
||||
var sendOk = a11y.clickFirstMatchingText("发送") || a11y.clickFirstMatchingText("Send")
|
||||
if (!sendOk && pasted) {
|
||||
val dm = context.resources.displayMetrics
|
||||
a11y.click((dm.widthPixels * 0.92f).toInt(), (dm.heightPixels * 0.62f).toInt())
|
||||
sendOk = true
|
||||
}
|
||||
delay(400)
|
||||
return if (sendOk) {
|
||||
mapOf(
|
||||
"success" to true,
|
||||
"message_id" to "a11y_${System.currentTimeMillis()}",
|
||||
"channel" to "no_root_a11y",
|
||||
"attempted" to true,
|
||||
"verified" to false,
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
"success" to false,
|
||||
"error" to "未点到「发送」(请确认已进入会话且键盘已弹出)",
|
||||
"channel" to "no_root_a11y",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.system.cloudservice.frida
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.Socket
|
||||
|
||||
class FridaBridge(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private const val FRIDA_HOST = "127.0.0.1"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
|
||||
private val fridaPort: Int
|
||||
get() = prefs.getInt("frida_port", 27042).coerceIn(1025, 65535)
|
||||
|
||||
private val serverManager = FridaServerManager(context, scope) { fridaPort }
|
||||
private val hookExecutor = HookExecutor()
|
||||
private val gson = Gson()
|
||||
|
||||
var isReady = false
|
||||
private set
|
||||
|
||||
fun init(): Boolean {
|
||||
if (!serverManager.hasRoot()) {
|
||||
Logger.i("FridaBridge: 无 Root,跳过 frida-server;微信统一指令由 SkillExecutor 走无障碍无 Root 通道")
|
||||
return false
|
||||
}
|
||||
|
||||
val started = serverManager.start()
|
||||
if (!started) {
|
||||
Logger.w("FridaBridge: server failed to start")
|
||||
return false
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
isReady = serverManager.isServerListening()
|
||||
if (isReady) {
|
||||
Logger.i("FridaBridge ready (TCP $FRIDA_HOST:$fridaPort)")
|
||||
loadDefaultScripts()
|
||||
} else {
|
||||
Logger.w("FridaBridge: server started but not listening")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
isReady = false
|
||||
serverManager.stop()
|
||||
}
|
||||
|
||||
fun execute(script: String, action: String, params: Map<String, Any?>): Map<String, Any?> {
|
||||
if (!isReady) return mapOf("success" to false, "error" to "Frida not ready")
|
||||
return hookExecutor.execute(script, action, params)
|
||||
}
|
||||
|
||||
fun getStatus(): Map<String, Any?> = mapOf(
|
||||
"ready" to isReady,
|
||||
// 当前 APK 只确认 frida-server 端口可达,尚未持有微信 Session/Script。
|
||||
// 必须保持 false,避免 UI 与心跳把“服务就绪”误报成“微信已注入”。
|
||||
"hooked" to false,
|
||||
"server_running" to serverManager.isRunning,
|
||||
"root_available" to serverManager.hasRoot(),
|
||||
)
|
||||
|
||||
fun isTargetHooked(): Boolean = false
|
||||
|
||||
private fun loadDefaultScripts() {
|
||||
try {
|
||||
val assets = context.assets.list("") ?: return
|
||||
if ("wechat_hook_v2.js" in assets) {
|
||||
val js = context.assets.open("wechat_hook_v2.js").bufferedReader().readText()
|
||||
hookExecutor.registerScript("wechat", js)
|
||||
Logger.i("FridaBridge: loaded wechat_hook_v2.js")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w("FridaBridge: failed to load default scripts: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun sendRpc(method: String, args: List<Any?> = emptyList()): Map<String, Any?>? {
|
||||
return try {
|
||||
Socket(FRIDA_HOST, fridaPort).use { socket ->
|
||||
socket.soTimeout = 10_000
|
||||
val writer = OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
|
||||
val request = mapOf(
|
||||
"type" to "rpc",
|
||||
"method" to method,
|
||||
"args" to args,
|
||||
)
|
||||
writer.write(gson.toJson(request) + "\n")
|
||||
writer.flush()
|
||||
|
||||
val response = reader.readLine() ?: return null
|
||||
gson.fromJson(response, Map::class.java) as? Map<String, Any?>
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.d("FridaBridge RPC error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.system.cloudservice.frida
|
||||
|
||||
import android.content.Context
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
class FridaServerManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val portProvider: () -> Int = { 27042 },
|
||||
) {
|
||||
companion object {
|
||||
private const val FRIDA_DIR = "/data/local/tmp"
|
||||
private val FRIDA_ASSET_NAME = "frida-server-arm64"
|
||||
private val PROCESS_NAMES = listOf("fs-agent", "media-sync", "sys-update")
|
||||
}
|
||||
|
||||
private var processName = PROCESS_NAMES.random()
|
||||
var isRunning = false
|
||||
private set
|
||||
|
||||
fun hasRoot(): Boolean = ShellExecutor.hasRoot()
|
||||
|
||||
fun start(): Boolean {
|
||||
if (!hasRoot()) {
|
||||
Logger.i("FridaServer: no root, skipping")
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val port = portProvider().coerceIn(1025, 65535)
|
||||
if (isServerListening()) {
|
||||
isRunning = true
|
||||
Logger.i("FridaServer already listening on port $port")
|
||||
return true
|
||||
}
|
||||
|
||||
killExisting()
|
||||
val serverPath = extractServer() ?: run {
|
||||
val listening = isServerListening()
|
||||
isRunning = listening
|
||||
return listening
|
||||
}
|
||||
val result = ShellExecutor.execute(
|
||||
"chmod 755 $serverPath && nohup $serverPath -l 0.0.0.0:$port >/dev/null 2>&1 &", 5
|
||||
)
|
||||
if (result.success || isServerListening()) {
|
||||
isRunning = true
|
||||
Logger.i("FridaServer started as '$processName' on port $port")
|
||||
true
|
||||
} else {
|
||||
Logger.w("FridaServer failed to start: ${result.output}")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FridaServer start error", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
try {
|
||||
ShellExecutor.execute("pkill -f frida-server 2>/dev/null; pkill -f $processName 2>/dev/null", 3)
|
||||
isRunning = false
|
||||
Logger.i("FridaServer stopped")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FridaServer stop error", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun isServerListening(): Boolean {
|
||||
val port = portProvider().coerceIn(1025, 65535)
|
||||
if (canConnect(port)) return true
|
||||
|
||||
val portHex = port.toString(16).uppercase().padStart(4, '0')
|
||||
val checks = listOf(
|
||||
"cat /proc/net/tcp 2>/dev/null | grep -i ':$portHex'",
|
||||
"cat /proc/net/tcp6 2>/dev/null | grep -i ':$portHex'",
|
||||
"ss -ltn 2>/dev/null | grep ':$port'",
|
||||
"netstat -ltn 2>/dev/null | grep ':$port'"
|
||||
)
|
||||
return checks.any { command ->
|
||||
val r = ShellExecutor.execute(command, 2)
|
||||
r.success && r.output.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canConnect(port: Int): Boolean {
|
||||
return try {
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress("127.0.0.1", port), 800)
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractServer(): String? {
|
||||
val destPath = "$FRIDA_DIR/$processName"
|
||||
val destFile = File(destPath)
|
||||
|
||||
if (destFile.exists() && destFile.length() > 1_000_000) {
|
||||
return destPath
|
||||
}
|
||||
|
||||
return try {
|
||||
val assetFiles = context.assets.list("") ?: emptyArray()
|
||||
if (FRIDA_ASSET_NAME !in assetFiles) {
|
||||
Logger.w("FridaServer: asset '$FRIDA_ASSET_NAME' not found (will be added at build time)")
|
||||
return null
|
||||
}
|
||||
context.assets.open(FRIDA_ASSET_NAME).use { input ->
|
||||
val tmpFile = File(context.cacheDir, processName)
|
||||
FileOutputStream(tmpFile).use { output -> input.copyTo(output) }
|
||||
ShellExecutor.execute("cp ${tmpFile.absolutePath} $destPath && chmod 755 $destPath", 10)
|
||||
tmpFile.delete()
|
||||
}
|
||||
if (File(destPath).exists()) destPath else null
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FridaServer extract error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun killExisting() {
|
||||
ShellExecutor.execute("pkill -f frida-server 2>/dev/null", 3)
|
||||
for (name in PROCESS_NAMES) {
|
||||
ShellExecutor.execute("pkill -f $name 2>/dev/null", 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.system.cloudservice.frida
|
||||
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
|
||||
class HookExecutor {
|
||||
|
||||
private val scripts = mutableMapOf<String, String>()
|
||||
|
||||
private val ACTION_MAP = mapOf(
|
||||
"wechat" to setOf(
|
||||
"send_message", "get_messages", "get_contacts", "add_friend",
|
||||
"accept_friend", "post_moment", "like_moment", "get_groups",
|
||||
"get_unread_count", "search_contact", "create_group",
|
||||
),
|
||||
"douyin" to setOf(
|
||||
"send_message", "get_messages", "get_fans", "reply_comment",
|
||||
"like_video", "follow_user",
|
||||
),
|
||||
"xhs" to setOf("send_message", "get_messages", "like_note", "search"),
|
||||
"xianyu" to setOf("send_message", "get_messages"),
|
||||
"soul" to setOf("send_message", "get_messages"),
|
||||
)
|
||||
|
||||
fun registerScript(name: String, js: String) {
|
||||
scripts[name] = js
|
||||
Logger.d("HookExecutor: registered script '$name' (${js.length} bytes)")
|
||||
}
|
||||
|
||||
fun supports(script: String, action: String): Boolean {
|
||||
return ACTION_MAP[script]?.contains(action) == true
|
||||
}
|
||||
|
||||
fun execute(script: String, action: String, params: Map<String, Any?>): Map<String, Any?> {
|
||||
if (!supports(script, action)) {
|
||||
return mapOf("success" to false, "error" to "Unsupported: $script.$action")
|
||||
}
|
||||
|
||||
val targetPkg = when (script) {
|
||||
"wechat" -> "com.tencent.mm"
|
||||
"douyin" -> "com.ss.android.ugc.aweme"
|
||||
"xhs" -> "com.xingin.xhs"
|
||||
"xianyu" -> "com.taobao.idlefish"
|
||||
"soul" -> "cn.soulapp.android"
|
||||
else -> return mapOf("success" to false, "error" to "Unknown script: $script")
|
||||
}
|
||||
|
||||
return try {
|
||||
val jsCode = scripts[script]
|
||||
if (jsCode != null) {
|
||||
executeViaFridaCli(targetPkg, jsCode, action, params)
|
||||
} else {
|
||||
mapOf("success" to false, "error" to "Script not loaded: $script")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("HookExecutor error [$script.$action]", e)
|
||||
mapOf<String, Any?>("success" to false, "error" to (e.message ?: "unknown"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeViaFridaCli(
|
||||
targetPkg: String, jsCode: String,
|
||||
action: String, params: Map<String, Any?>
|
||||
): Map<String, Any?> {
|
||||
val rpcCall = "rpc.exports.$action(${com.google.gson.Gson().toJson(params)})"
|
||||
val result = ShellExecutor.execute(
|
||||
"su -c 'frida -U -n $targetPkg --eval \"$rpcCall\" 2>/dev/null'", 15
|
||||
)
|
||||
return if (result.success) {
|
||||
mapOf("success" to true, "data" to result.output, "channel" to "frida")
|
||||
} else {
|
||||
mapOf("success" to false, "error" to result.output, "channel" to "frida")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.system.cloudservice.service
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class AgentAccessibilityService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "A11yService"
|
||||
private var instance: AgentAccessibilityService? = null
|
||||
fun getInstance(): AgentAccessibilityService? = instance
|
||||
|
||||
/** 服务已绑定到进程(正在运行) */
|
||||
fun isServiceBound(): Boolean = instance != null
|
||||
|
||||
/**
|
||||
* 是否在系统设置中为本应用开启了无障碍,或当前已绑定实例。
|
||||
* 仅用 [isServiceBound] 会在「已开启但尚未 bind」时误显示未启用。
|
||||
*/
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
if (instance != null) return true
|
||||
val expected = ComponentName(context, AgentAccessibilityService::class.java)
|
||||
val flat = Settings.Secure.getString(
|
||||
context.applicationContext.contentResolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
) ?: return false
|
||||
if (flat.isEmpty()) return false
|
||||
return flat.split(':').mapNotNull { ComponentName.unflattenFromString(it) }
|
||||
.any { it == expected }
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业授权设备可由 ADB 一次性授予 WRITE_SECURE_SETTINGS,之后冷启动自动补齐
|
||||
* 本应用无障碍服务。保留系统中已经启用的其他服务,不覆盖用户配置。
|
||||
*/
|
||||
fun enableIfAuthorized(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
|
||||
context.checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) return false
|
||||
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val expected = ComponentName(context, AgentAccessibilityService::class.java)
|
||||
.flattenToString()
|
||||
val current = Settings.Secure.getString(
|
||||
resolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
).orEmpty().split(':').filter { it.isNotBlank() }.toMutableList()
|
||||
if (expected !in current) current += expected
|
||||
Settings.Secure.putString(
|
||||
resolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
current.distinct().joinToString(":"),
|
||||
) && Settings.Secure.putInt(
|
||||
resolver,
|
||||
Settings.Secure.ACCESSIBILITY_ENABLED,
|
||||
1,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "WRITE_SECURE_SETTINGS 未授权,保留手动授权流程", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
Log.d(TAG, "Accessibility service connected")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
instance = null
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
|
||||
override fun onInterrupt() {}
|
||||
|
||||
fun click(x: Int, y: Int, callback: ((Boolean) -> Unit)? = null) {
|
||||
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, 0, 100))
|
||||
.build()
|
||||
dispatchGesture(gesture, object : GestureResultCallback() {
|
||||
override fun onCompleted(g: GestureDescription?) { callback?.invoke(true) }
|
||||
override fun onCancelled(g: GestureDescription?) { callback?.invoke(false) }
|
||||
}, null)
|
||||
}
|
||||
|
||||
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, duration: Long = 300, callback: ((Boolean) -> Unit)? = null) {
|
||||
val path = Path().apply {
|
||||
moveTo(x1.toFloat(), y1.toFloat())
|
||||
lineTo(x2.toFloat(), y2.toFloat())
|
||||
}
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, 0, duration))
|
||||
.build()
|
||||
dispatchGesture(gesture, object : GestureResultCallback() {
|
||||
override fun onCompleted(g: GestureDescription?) { callback?.invoke(true) }
|
||||
override fun onCancelled(g: GestureDescription?) { callback?.invoke(false) }
|
||||
}, null)
|
||||
}
|
||||
|
||||
fun inputText(text: String, callback: ((Boolean) -> Unit)? = null) {
|
||||
scope.launch {
|
||||
val root = rootInActiveWindow ?: run { callback?.invoke(false); return@launch }
|
||||
val inputNode = findInputNode(root)
|
||||
if (inputNode != null) {
|
||||
inputNode.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||
delay(200)
|
||||
val args = Bundle().apply {
|
||||
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
|
||||
}
|
||||
val ok = inputNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
|
||||
inputNode.recycle()
|
||||
callback?.invoke(ok)
|
||||
} else {
|
||||
callback?.invoke(false)
|
||||
}
|
||||
root.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun back() { performGlobalAction(GLOBAL_ACTION_BACK) }
|
||||
fun home() { performGlobalAction(GLOBAL_ACTION_HOME) }
|
||||
fun recent() { performGlobalAction(GLOBAL_ACTION_RECENTS) }
|
||||
|
||||
fun getUITree(): String {
|
||||
val root = rootInActiveWindow ?: return ""
|
||||
val sb = StringBuilder()
|
||||
dumpNode(root, sb, 0)
|
||||
root.recycle()
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** 无 Root:按可见文案查找节点并点击(优先 ACTION_CLICK,失败则手势点中心) */
|
||||
fun clickFirstMatchingText(substring: String): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val target = findNodeWithTextContains(root, substring) ?: run {
|
||||
root.recycle()
|
||||
return false
|
||||
}
|
||||
val clicked = target.performAction(AccessibilityNodeInfo.ACTION_CLICK) ||
|
||||
run {
|
||||
val r = Rect()
|
||||
target.getBoundsInScreen(r)
|
||||
click(r.centerX(), r.centerY())
|
||||
true
|
||||
}
|
||||
target.recycle()
|
||||
root.recycle()
|
||||
return clicked
|
||||
}
|
||||
|
||||
private fun nodeTextBundle(node: AccessibilityNodeInfo): String {
|
||||
val hint = if (Build.VERSION.SDK_INT >= 26) node.hintText?.toString().orEmpty() else ""
|
||||
return "${node.text ?: ""}${node.contentDescription ?: ""}$hint"
|
||||
}
|
||||
|
||||
private fun findNodeWithTextContains(node: AccessibilityNodeInfo, needle: String): AccessibilityNodeInfo? {
|
||||
if (nodeTextBundle(node).contains(needle)) return node
|
||||
for (i in 0 until node.childCount) {
|
||||
val c = node.getChild(i) ?: continue
|
||||
val f = findNodeWithTextContains(c, needle)
|
||||
if (f != null) {
|
||||
// f 可能就是 c;此处提前 recycle 会让调用方拿到失效节点,
|
||||
// 微信首页明明存在“搜索”仍会点击失败。
|
||||
return f
|
||||
}
|
||||
c.recycle()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findInputNode(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
|
||||
// 微信输入框常用自定义 View,不保证类名包含 EditText;以可编辑能力为准。
|
||||
if (node.isEditable ||
|
||||
node.className?.contains("EditText") == true ||
|
||||
node.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT }
|
||||
) return node
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
findInputNode(child)?.let { return it }
|
||||
child.recycle()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun dumpNode(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
|
||||
val indent = " ".repeat(depth)
|
||||
sb.append("$indent${node.className}")
|
||||
node.text?.let { sb.append(" text=\"$it\"") }
|
||||
node.contentDescription?.let { sb.append(" desc=\"$it\"") }
|
||||
sb.append("\n")
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
dumpNode(child, sb, depth + 1)
|
||||
child.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.system.cloudservice.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.system.cloudservice.BuildConfig
|
||||
import com.system.cloudservice.ai.AIBrain
|
||||
import com.system.cloudservice.ai.LLMClient
|
||||
import com.system.cloudservice.antiban.AntiBanManager
|
||||
import com.system.cloudservice.engine.AgentEngine
|
||||
import com.system.cloudservice.engine.ConnectionState
|
||||
import com.system.cloudservice.engine.SkillExecutor
|
||||
import com.system.cloudservice.frida.FridaBridge
|
||||
import com.system.cloudservice.ui.MainActivity
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class AgentForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "sync_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val ACTION_START = "com.system.cloudservice.START"
|
||||
const val ACTION_STOP = "com.system.cloudservice.STOP"
|
||||
const val EXTRA_SERVER_URL = "server_url"
|
||||
const val EXTRA_PROJECT_ID = "project_id"
|
||||
const val EXTRA_DEVICE_ID = "device_id"
|
||||
|
||||
var instance: AgentForegroundService? = null
|
||||
private set
|
||||
|
||||
var isRunning = false
|
||||
private set
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var engineStartJob: Job? = null
|
||||
var engine: AgentEngine? = null
|
||||
private set
|
||||
|
||||
// BIND-05:换 Wi-Fi / 恢复 4G 时触发重寻服
|
||||
private var connectivityManager: ConnectivityManager? = null
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Logger.i("网络可用 → 通知 engine 重寻服")
|
||||
engine?.onNetworkAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
registerNetworkCallback()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
engine?.stop()
|
||||
try { connectivityManager?.unregisterNetworkCallback(networkCallback) } catch (_: Exception) {}
|
||||
serviceScope.cancel()
|
||||
instance = null
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private fun registerNetworkCallback() {
|
||||
try {
|
||||
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
val req = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager?.registerNetworkCallback(req, networkCallback)
|
||||
} catch (e: Exception) {
|
||||
Logger.w("注册网络回调失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL) ?: ""
|
||||
val projectId = intent.getStringExtra(EXTRA_PROJECT_ID) ?: ""
|
||||
var deviceId = intent.getStringExtra(EXTRA_DEVICE_ID)?.trim().orEmpty()
|
||||
if (deviceId.isEmpty()) deviceId = DeviceInfo.getOrCreateDeviceId(this)
|
||||
|
||||
isRunning = true
|
||||
startForeground(NOTIFICATION_ID, createNotification("正在连接..."))
|
||||
synchronized(this) {
|
||||
if (engine != null || engineStartJob?.isActive == true) {
|
||||
Logger.i("AgentEngine 已存在或正在启动,忽略重复 START,仅触发重连")
|
||||
engine?.onNetworkAvailable()
|
||||
} else {
|
||||
initAndStartEngine(serverUrl, projectId, deviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
engine?.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun initAndStartEngine(serverUrlIn: String, projectId: String, deviceId: String) {
|
||||
engineStartJob = serviceScope.launch {
|
||||
// BIND-05:未绑定 server_url 时,先走 UDP beacon 自动发现(同网零配置)
|
||||
var serverUrl = serverUrlIn
|
||||
if (serverUrl.isBlank()) {
|
||||
Logger.i("无 server_url → beacon 自动发现 SDK")
|
||||
val found = com.system.cloudservice.engine.BeaconDiscovery.discover(20_000L)
|
||||
if (found != null) {
|
||||
serverUrl = found.wsUrl
|
||||
Logger.i("beacon 命中: $serverUrl")
|
||||
} else {
|
||||
Logger.w("beacon 未发现 SDK,等待网络回调或扫码绑定")
|
||||
}
|
||||
}
|
||||
|
||||
val antiBan = AntiBanManager(serviceScope)
|
||||
antiBan.init()
|
||||
|
||||
val hasRoot = com.system.cloudservice.util.ShellExecutor.hasRoot()
|
||||
Logger.i("Runtime root detection: hasRoot=$hasRoot")
|
||||
if (!hasRoot) {
|
||||
Logger.i("无 Root 模式:微信 send_message/get_wechat_version 等由无障碍执行,非进程内 Hook")
|
||||
}
|
||||
val fridaBridge = if (hasRoot) FridaBridge(this@AgentForegroundService, serviceScope) else null
|
||||
val fridaStarted = fridaBridge?.init() ?: false
|
||||
if (fridaStarted) {
|
||||
repeat(15) {
|
||||
if (fridaBridge?.isReady == true) return@repeat
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
val fridaReady = fridaBridge?.isReady == true
|
||||
|
||||
val prefs = getSharedPreferences("agent_config", MODE_PRIVATE)
|
||||
val aiApiKey = prefs.getString("ai_api_key", "")?.trim().orEmpty()
|
||||
val aiEnabled = prefs.getBoolean("ai_enabled", aiApiKey.isNotEmpty())
|
||||
val aiBrain = if (aiEnabled && aiApiKey.isNotEmpty()) {
|
||||
val llm = LLMClient(
|
||||
apiUrl = prefs.getString("ai_api_url", BuildConfig.AI_API_URL) ?: BuildConfig.AI_API_URL,
|
||||
apiKey = aiApiKey,
|
||||
model = prefs.getString("ai_model", "auto") ?: "auto",
|
||||
)
|
||||
AIBrain(
|
||||
llm,
|
||||
brainInterval = prefs.getInt("ai_brain_interval", 60),
|
||||
initialStandingOrders = listOf(
|
||||
"检查微信是否已登录,未登录则 ensure_logged_in",
|
||||
"检查微信未读消息",
|
||||
),
|
||||
enabled = true,
|
||||
)
|
||||
} else null
|
||||
|
||||
val skillExecutor = SkillExecutor(
|
||||
this@AgentForegroundService,
|
||||
if (fridaReady) fridaBridge else null,
|
||||
antiBan,
|
||||
)
|
||||
|
||||
engine = AgentEngine(
|
||||
context = this@AgentForegroundService,
|
||||
skillExecutor = skillExecutor,
|
||||
aiBrain = aiBrain,
|
||||
antiBan = antiBan,
|
||||
fridaBridge = if (fridaReady) fridaBridge else null,
|
||||
)
|
||||
|
||||
engine!!.onStateChange = { state ->
|
||||
val text = when (state) {
|
||||
ConnectionState.CONNECTED -> "已连接 - $projectId"
|
||||
ConnectionState.CONNECTING -> "正在连接..."
|
||||
ConnectionState.DISCONNECTED -> "已断开"
|
||||
}
|
||||
updateNotification(text)
|
||||
}
|
||||
|
||||
// BIND-03:公网主服有序回退列表(QR / 设置写入 prefs,逗号分隔)
|
||||
val publicServers = prefs.getString("public_servers", "")
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?: emptyList()
|
||||
|
||||
engine!!.start(serverUrl, projectId, deviceId, publicServers)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, "同步", NotificationManager.IMPORTANCE_MIN).apply {
|
||||
description = "后台同步"
|
||||
setShowBadge(false)
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(status: String): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("工作手机")
|
||||
.setContentText(sanitize(status))
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(pi)
|
||||
.setOngoing(true).setOnlyAlertOnce(true).setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun updateNotification(status: String) {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.notify(NOTIFICATION_ID, createNotification(status))
|
||||
}
|
||||
|
||||
private fun sanitize(s: String): String = when {
|
||||
s.contains("正在连接") -> "正在同步数据"
|
||||
s.contains("已连接") -> "同步服务已就绪"
|
||||
s.contains("重连") -> "正在恢复同步"
|
||||
s.contains("已断开") -> "同步已暂停"
|
||||
else -> "同步服务运行中"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.system.cloudservice.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED || context == null) return
|
||||
|
||||
val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
val projectId = prefs.getString("project_id", "default") ?: "default"
|
||||
val deviceId = prefs.getString("device_id", "")?.takeIf { it.isNotBlank() }
|
||||
?: DeviceInfo.getOrCreateDeviceId(context)
|
||||
// BIND-05:已绑定(server_url) 或 显式开启 auto_discover 时开机自启;
|
||||
// 后者允许「未填地址也开机后 beacon 自动寻服」(默认关,避免未配置设备空转)
|
||||
val autoDiscover = prefs.getBoolean("auto_discover", false)
|
||||
|
||||
if (serverUrl.isNotEmpty() || autoDiscover) {
|
||||
val svcIntent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_START
|
||||
putExtra(AgentForegroundService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(AgentForegroundService.EXTRA_PROJECT_ID, projectId)
|
||||
putExtra(AgentForegroundService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(svcIntent)
|
||||
} else {
|
||||
context.startService(svcIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.engine.ConnectionState
|
||||
import com.system.cloudservice.engine.LanDiscovery
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class DashboardFragment : Fragment() {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var capabilityPoll: Job? = null
|
||||
private val prefs by lazy { requireContext().getSharedPreferences("agent_config", 0) }
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_dashboard, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
view.findViewById<TextView>(R.id.tvDeviceIdHeader).text = "设备 ID · MD5"
|
||||
view.findViewById<TextView>(R.id.tvDeviceIdMd5).text = DeviceInfo.deviceIdMd5(requireContext())
|
||||
|
||||
view.findViewById<ImageButton>(R.id.btnScanQr).setOnClickListener { startQrScan() }
|
||||
view.findViewById<Button>(R.id.btnReconnect).setOnClickListener { reconnect() }
|
||||
view.findViewById<Button>(R.id.btnRestartService).setOnClickListener { restartService() }
|
||||
|
||||
wireCapabilityShortcuts(view)
|
||||
refresh(view)
|
||||
}
|
||||
|
||||
/** 点击能力标签:跳转系统设置或本应用设置,便于把灰色项点亮 */
|
||||
private fun wireCapabilityShortcuts(rootView: View) {
|
||||
val ctx = requireContext()
|
||||
fun safeStart(i: Intent) {
|
||||
try {
|
||||
startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(ctx, e.message ?: "无法打开", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
rootView.findViewById<TextView>(R.id.tvCapRoot).apply {
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
Toast.makeText(ctx, "请在 Magisk「超级用户」中为本应用勾选永久授权", Toast.LENGTH_LONG).show()
|
||||
safeStart(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${ctx.packageName}")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
rootView.findViewById<TextView>(R.id.tvCapHook).apply {
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
Toast.makeText(ctx, "Hook 需 Root;将尝试重启同步服务", Toast.LENGTH_SHORT).show()
|
||||
restartService()
|
||||
scope.launch { delay(1200); view?.let { refresh(it) } }
|
||||
}
|
||||
}
|
||||
rootView.findViewById<TextView>(R.id.tvCapA11y).apply {
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
}
|
||||
rootView.findViewById<TextView>(R.id.tvCapAntiBan).apply {
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
Toast.makeText(ctx, "防封模块随同步服务运行", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
rootView.findViewById<TextView>(R.id.tvCapWechat).apply {
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
val launch = ctx.packageManager.getLaunchIntentForPackage("com.tencent.mm")
|
||||
if (launch != null) {
|
||||
startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} else {
|
||||
Toast.makeText(ctx, "未安装微信", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
rootView.findViewById<TextView>(R.id.tvCapAI).apply {
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
startActivity(
|
||||
Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||
putExtra(MainActivity.EXTRA_OPEN_SETTINGS, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
view?.let { refresh(it) }
|
||||
capabilityPoll?.cancel()
|
||||
capabilityPoll = scope.launch {
|
||||
repeat(15) {
|
||||
delay(1000)
|
||||
if (!isAdded) return@launch
|
||||
val v = view ?: return@launch
|
||||
refresh(v)
|
||||
if (AgentForegroundService.instance?.engine?.wsManager?.state?.value == ConnectionState.CONNECTED) {
|
||||
refresh(v)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
capabilityPoll?.cancel()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun refresh(view: View) {
|
||||
val svc = AgentForegroundService.instance
|
||||
val engine = svc?.engine
|
||||
val connState = engine?.wsManager?.state?.value ?: ConnectionState.DISCONNECTED
|
||||
val isOnline = connState == ConnectionState.CONNECTED
|
||||
|
||||
val statusView = view.findViewById<TextView>(R.id.tvOnlineStatus)
|
||||
statusView.text = if (isOnline) "在线" else if (connState == ConnectionState.CONNECTING) "连接中" else "离线"
|
||||
statusView.setTextColor(resources.getColor(
|
||||
if (isOnline) R.color.accent_green
|
||||
else if (connState == ConnectionState.CONNECTING) R.color.accent_orange
|
||||
else R.color.accent_red, null
|
||||
))
|
||||
statusView.setBackgroundResource(
|
||||
if (isOnline) R.drawable.bg_pill_online
|
||||
else if (connState == ConnectionState.CONNECTING) R.drawable.bg_pill_connecting
|
||||
else R.drawable.bg_pill_offline
|
||||
)
|
||||
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
view.findViewById<TextView>(R.id.tvServerAddr).text =
|
||||
"服务器 ${if (serverUrl.isEmpty()) "未绑定" else serverUrl}"
|
||||
view.findViewById<TextView>(R.id.tvProjectId).text =
|
||||
"项目 ${prefs.getString("project_id", "—") ?: "—"}"
|
||||
|
||||
// BIND-07 connect_stage 人话映射
|
||||
val stageRaw = engine?.getConnectStage()
|
||||
val stageText = when (stageRaw) {
|
||||
"primary" -> "主服务器"
|
||||
"lan" -> "局域网服务器"
|
||||
"public" -> "公网备用"
|
||||
null -> "—"
|
||||
else -> stageRaw
|
||||
}
|
||||
view.findViewById<TextView>(R.id.tvConnectStage).text = "连接阶段 $stageText"
|
||||
|
||||
// §〇 四端互通状态:SDK+WS 双绿时存客宝/触客宝/AI 数智员工可调
|
||||
val interopView = view.findViewById<TextView>(R.id.tvInteropStatus)
|
||||
val interopDetailView = view.findViewById<TextView>(R.id.tvInteropDetail)
|
||||
if (isOnline && engine != null) {
|
||||
interopView.text = "在线"
|
||||
interopView.setTextColor(resources.getColor(R.color.accent_green, null))
|
||||
interopView.setBackgroundResource(R.drawable.bg_pill_online)
|
||||
interopDetailView.text = "存客宝 · 触客宝 · AI 数智员工 可调"
|
||||
interopDetailView.setTextColor(resources.getColor(R.color.accent_green, null))
|
||||
} else {
|
||||
interopView.text = "离线"
|
||||
interopView.setTextColor(resources.getColor(R.color.accent_red, null))
|
||||
interopView.setBackgroundResource(R.drawable.bg_pill_offline)
|
||||
interopDetailView.text = "等待 SDK + WS 双绿"
|
||||
interopDetailView.setTextColor(resources.getColor(R.color.text_secondary, null))
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val ctx = requireContext()
|
||||
val info = DeviceInfo.collectQuick(ctx)
|
||||
val hasRoot = ShellExecutor.hasRoot()
|
||||
val wechatRunning = info["wechat_running"] == true
|
||||
val wechatInstalled = DeviceInfo.isWeChatInstalled(ctx)
|
||||
val a11yOn = AgentAccessibilityService.isEnabled(ctx)
|
||||
val hookOk = engine?.isWechatHookAttached() == true
|
||||
val wechatCap = wechatInstalled && (wechatRunning || isOnline)
|
||||
val aiCap = engine != null || AgentForegroundService.isRunning
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!isAdded) return@withContext
|
||||
|
||||
view.findViewById<TextView>(R.id.tvHardwareInfo).text = buildString {
|
||||
append("${Build.MODEL} · Android ${Build.VERSION.RELEASE}")
|
||||
append("\n电量 ${info["battery_level"]}%")
|
||||
if (info["battery_charging"] == true) append(" 充电中")
|
||||
append(" · 内存 ${info["memory_usage_pct"]}%")
|
||||
append("\n存储余 ${info["storage_free_mb"]}MB · ${info["network_type"] ?: "未知"}网络")
|
||||
val wxVersion = info["wechat_version"]?.toString().orEmpty()
|
||||
if (wxVersion.isNotEmpty()) append("\n微信 $wxVersion")
|
||||
// WP-CKB-143 wxid(root 读 SharedPreferences login_weixin_username,Frida 兜底)
|
||||
val wxid = info["wxid"]?.toString().orEmpty()
|
||||
append("\nwxid ${if (wxid.isNotEmpty()) wxid else if (hookOk) "待 Hook 注入" else "—"}")
|
||||
}
|
||||
|
||||
setCapability(view, R.id.tvCapRoot, "Root", hasRoot)
|
||||
setCapability(view, R.id.tvCapHook, "Hook", hookOk)
|
||||
setCapability(view, R.id.tvCapA11y, "无障碍", a11yOn)
|
||||
setCapability(view, R.id.tvCapAntiBan, "防封", true)
|
||||
setCapability(view, R.id.tvCapWechat, "微信", wechatCap)
|
||||
setCapability(view, R.id.tvCapAI, "AI", aiCap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCapability(view: View, id: Int, label: String, enabled: Boolean) {
|
||||
val tv = view.findViewById<TextView>(id)
|
||||
tv.text = if (enabled) "✓ $label" else "$label"
|
||||
tv.setBackgroundResource(if (enabled) R.drawable.bg_capability_on else R.drawable.bg_capability_off)
|
||||
tv.setTextColor(resources.getColor(
|
||||
if (enabled) R.color.accent_green else R.color.text_tertiary, null
|
||||
))
|
||||
}
|
||||
|
||||
private fun startQrScan() {
|
||||
@Suppress("DEPRECATION")
|
||||
IntentIntegrator.forSupportFragment(this)
|
||||
.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
.setPrompt("对准服务器二维码")
|
||||
.setCameraId(0)
|
||||
.setBeepEnabled(false)
|
||||
.setBarcodeImageEnabled(false)
|
||||
.setOrientationLocked(true)
|
||||
.initiateScan()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
|
||||
if (result?.contents != null) {
|
||||
try {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
val config: Map<String, String> = Gson().fromJson(result.contents, type)
|
||||
val serverUrl = config["server"] ?: return
|
||||
val projectId = config["project"] ?: "cunkebao"
|
||||
prefs.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("project_id", projectId)
|
||||
.putString("pairing_token", config["pairing_token"] ?: "")
|
||||
.putBoolean("auto_connect", true)
|
||||
.putBoolean("bound", true)
|
||||
.apply()
|
||||
restartService()
|
||||
scope.launch { delay(1000); view?.let { refresh(it) } }
|
||||
} catch (e: Exception) {
|
||||
Logger.e("QR parse error", e)
|
||||
}
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnect() {
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
if (serverUrl.isEmpty()) {
|
||||
startLanDiscovery()
|
||||
return
|
||||
}
|
||||
restartService()
|
||||
scope.launch { delay(1500); view?.let { refresh(it) } }
|
||||
}
|
||||
|
||||
private fun startLanDiscovery() {
|
||||
scope.launch {
|
||||
try {
|
||||
val localIp = withContext(Dispatchers.IO) { getLocalIp() }
|
||||
val servers = LanDiscovery.discover(localIp)
|
||||
if (servers.isNotEmpty()) {
|
||||
val best = servers.first()
|
||||
prefs.edit()
|
||||
.putString("server_url", best.wsUrl)
|
||||
.putString("project_id", "cunkebao")
|
||||
.putBoolean("auto_connect", true)
|
||||
.putBoolean("bound", true)
|
||||
.apply()
|
||||
restartService()
|
||||
delay(1000)
|
||||
view?.let { refresh(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Dashboard LAN scan failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLocalIp(): String? {
|
||||
try {
|
||||
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
|
||||
while (interfaces.hasMoreElements()) {
|
||||
val ni = interfaces.nextElement()
|
||||
if (!ni.isUp || ni.isLoopback) continue
|
||||
val addrs = ni.inetAddresses
|
||||
while (addrs.hasMoreElements()) {
|
||||
val addr = addrs.nextElement()
|
||||
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
|
||||
return addr.hostAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun restartService() {
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
val projectId = prefs.getString("project_id", "default") ?: "default"
|
||||
val deviceId = DeviceInfo.getOrCreateDeviceId(requireContext())
|
||||
val intent = Intent(requireContext(), AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_START
|
||||
putExtra(AgentForegroundService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(AgentForegroundService.EXTRA_PROJECT_ID, projectId)
|
||||
putExtra(AgentForegroundService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) requireContext().startForegroundService(intent)
|
||||
else requireContext().startService(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.gson.Gson
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
|
||||
class HubFragment : Fragment() {
|
||||
|
||||
private var webView: WebView? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
webView = WebView(requireContext()).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.allowFileAccess = true
|
||||
settings.allowContentAccess = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
webViewClient = WebViewClient()
|
||||
webChromeClient = WebChromeClient()
|
||||
addJavascriptInterface(NativeAgentBridge(), "NativeAgent")
|
||||
loadUrl("file:///android_asset/hub.html")
|
||||
}
|
||||
return webView!!
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
fun canGoBack(): Boolean = webView?.canGoBack() ?: false
|
||||
fun goBack() { webView?.goBack() }
|
||||
|
||||
inner class NativeAgentBridge {
|
||||
private val gson = Gson()
|
||||
|
||||
@JavascriptInterface
|
||||
fun getDeviceStatus(): String {
|
||||
return try {
|
||||
val status = DeviceInfo.collectQuick(requireContext())
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
val extra = mapOf(
|
||||
"connected" to (engine?.wsManager?.isConnected ?: false),
|
||||
"engine_running" to (AgentForegroundService.isRunning),
|
||||
)
|
||||
gson.toJson(status + extra)
|
||||
} catch (e: Exception) {
|
||||
gson.toJson(mapOf("error" to e.message))
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun executeCommand(actionJson: String): String {
|
||||
return try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cmd = gson.fromJson(actionJson, Map::class.java) as Map<String, Any?>
|
||||
val action = cmd["action"] as? String ?: ""
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = cmd["params"] as? Map<String, Any?> ?: emptyMap()
|
||||
val svc = AgentForegroundService.instance
|
||||
if (svc?.engine != null) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val result = svc.engine!!.let { eng ->
|
||||
eng.sendEvent("hub_command", mapOf("action" to action, "params" to params))
|
||||
}
|
||||
gson.toJson(mapOf<String, Any?>("code" to 200, "message" to "submitted", "action" to action))
|
||||
}
|
||||
} else {
|
||||
gson.toJson(mapOf<String, Any?>("code" to 503, "message" to "Engine not running"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
gson.toJson(mapOf<String, Any?>("code" to 500, "message" to (e.message ?: "error")))
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getConfig(): String {
|
||||
val prefs = requireContext().getSharedPreferences("agent_config", 0)
|
||||
return gson.toJson(mapOf(
|
||||
"server_url" to (prefs.getString("server_url", "") ?: ""),
|
||||
"project_id" to (prefs.getString("project_id", "") ?: ""),
|
||||
"device_id" to (prefs.getString("device_id", "") ?: ""),
|
||||
"pwa_url" to (prefs.getString("pwa_url", "") ?: ""),
|
||||
"ai_enabled" to prefs.getBoolean("ai_enabled", false),
|
||||
"ai_api_url" to (prefs.getString("ai_api_url", "") ?: ""),
|
||||
))
|
||||
}
|
||||
|
||||
/** 手机端小 AI:卡若网关 /api/gateway/chat → 决策 → 本机 Skill 执行 */
|
||||
@JavascriptInterface
|
||||
fun runAiChat(message: String): String {
|
||||
return try {
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
?: return gson.toJson(mapOf("code" to 503, "message" to "Agent 未运行"))
|
||||
kotlinx.coroutines.runBlocking {
|
||||
gson.toJson(engine.runAiChat(message))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("runAiChat failed", e)
|
||||
gson.toJson(mapOf("code" to 500, "message" to (e.message ?: "error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class LogFragment : Fragment() {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var refreshJob: Job? = null
|
||||
private val maxLines = 500
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_log, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
view.findViewById<Button>(R.id.btnClearLog).setOnClickListener {
|
||||
Logger.clearBuffer()
|
||||
view.findViewById<TextView>(R.id.tvLogContent).text = "日志已清空"
|
||||
}
|
||||
refreshLog(view)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshJob = scope.launch {
|
||||
while (isActive) {
|
||||
view?.let { refreshLog(it) }
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
refreshJob?.cancel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun refreshLog(view: View) {
|
||||
val logs = Logger.getRecentLogs(maxLines)
|
||||
val content = if (logs.isEmpty()) {
|
||||
"暂无日志...\n\n提示:当设备连接服务器并执行任务时,日志将实时显示在此处。"
|
||||
} else {
|
||||
logs.joinToString("\n")
|
||||
}
|
||||
view.findViewById<TextView>(R.id.tvLogContent).text = content
|
||||
val scrollView = view.findViewById<ScrollView>(R.id.logScrollView)
|
||||
scrollView.post { scrollView.fullScroll(ScrollView.FOCUS_DOWN) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.databinding.ActivityMainBinding
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_OPEN_SETTINGS = "open_settings"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val prefs by lazy { getSharedPreferences("agent_config", MODE_PRIVATE) }
|
||||
|
||||
private val dashboardFragment = DashboardFragment()
|
||||
private val wechatFragment = WechatFragment()
|
||||
private val settingsFragment = SettingsFragment()
|
||||
private var activeFragment: Fragment = dashboardFragment
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
setupFragments()
|
||||
setupBottomNav()
|
||||
autoConnect()
|
||||
handleOpenSettingsIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleOpenSettingsIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleOpenSettingsIntent(i: Intent?) {
|
||||
if (i?.getBooleanExtra(EXTRA_OPEN_SETTINGS, false) != true) return
|
||||
binding.bottomNav.selectedItemId = R.id.nav_settings
|
||||
switchFragment(settingsFragment)
|
||||
}
|
||||
|
||||
private fun setupFragments() {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.fragmentContainer, settingsFragment, "settings").hide(settingsFragment)
|
||||
.add(R.id.fragmentContainer, wechatFragment, "wechat").hide(wechatFragment)
|
||||
.add(R.id.fragmentContainer, dashboardFragment, "dashboard")
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun setupBottomNav() {
|
||||
binding.bottomNav.selectedItemId = R.id.nav_dashboard
|
||||
binding.bottomNav.setOnItemSelectedListener { item ->
|
||||
val target: Fragment = when (item.itemId) {
|
||||
R.id.nav_dashboard -> dashboardFragment
|
||||
R.id.nav_wechat -> wechatFragment
|
||||
R.id.nav_settings -> settingsFragment
|
||||
else -> dashboardFragment
|
||||
}
|
||||
switchFragment(target)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchFragment(target: Fragment) {
|
||||
if (target == activeFragment) return
|
||||
supportFragmentManager.beginTransaction()
|
||||
.hide(activeFragment)
|
||||
.show(target)
|
||||
.commit()
|
||||
activeFragment = target
|
||||
}
|
||||
|
||||
private fun autoConnect() {
|
||||
val auto = prefs.getBoolean("auto_connect", true)
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
val projectId = prefs.getString("project_id", "default") ?: "default"
|
||||
|
||||
if (auto && serverUrl.isNotEmpty() && !AgentForegroundService.isRunning) {
|
||||
val deviceId = DeviceInfo.getOrCreateDeviceId(this)
|
||||
val intent = Intent(this, AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_START
|
||||
putExtra(AgentForegroundService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(AgentForegroundService.EXTRA_PROJECT_ID, projectId)
|
||||
putExtra(AgentForegroundService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent)
|
||||
else startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.gson.Gson
|
||||
import com.system.cloudservice.BuildConfig
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
|
||||
class PWAFragment : Fragment() {
|
||||
|
||||
private var webView: WebView? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
webView = WebView(requireContext()).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.cacheMode = WebSettings.LOAD_DEFAULT
|
||||
settings.databaseEnabled = true
|
||||
settings.allowFileAccess = true
|
||||
addJavascriptInterface(PWABridge(), "NativeAgent")
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
injectDeviceContext()
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
val pwaUrl = requireContext().getSharedPreferences("agent_config", 0)
|
||||
.getString("pwa_url", BuildConfig.PWA_URL) ?: BuildConfig.PWA_URL
|
||||
loadUrl(pwaUrl)
|
||||
}
|
||||
return webView!!
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
fun canGoBack(): Boolean = webView?.canGoBack() ?: false
|
||||
fun goBack() { webView?.goBack() }
|
||||
|
||||
private fun injectDeviceContext() {
|
||||
val prefs = requireContext().getSharedPreferences("agent_config", 0)
|
||||
val deviceId = prefs.getString("device_id", "") ?: ""
|
||||
val projectId = prefs.getString("project_id", "") ?: ""
|
||||
val connected = AgentForegroundService.instance?.engine?.wsManager?.isConnected ?: false
|
||||
val js = """
|
||||
window.__DEVICE_CONTEXT__ = {
|
||||
deviceId: '$deviceId',
|
||||
projectId: '$projectId',
|
||||
connected: $connected,
|
||||
platform: 'android',
|
||||
appVersion: '4.0.0'
|
||||
};
|
||||
""".trimIndent()
|
||||
webView?.evaluateJavascript(js, null)
|
||||
}
|
||||
|
||||
inner class PWABridge {
|
||||
private val gson = Gson()
|
||||
|
||||
@JavascriptInterface
|
||||
fun getDeviceId(): String {
|
||||
return requireContext().getSharedPreferences("agent_config", 0)
|
||||
.getString("device_id", "") ?: ""
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getConnectionStatus(): String {
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
return gson.toJson(mapOf(
|
||||
"connected" to (engine?.wsManager?.isConnected ?: false),
|
||||
"running" to AgentForegroundService.isRunning,
|
||||
))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getDeviceInfo(): String {
|
||||
return try {
|
||||
gson.toJson(DeviceInfo.collectQuick(requireContext()))
|
||||
} catch (e: Exception) {
|
||||
Logger.e("PWABridge getDeviceInfo error", e)
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
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.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.gson.Gson
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
private val prefs by lazy { requireContext().getSharedPreferences("agent_config", Context.MODE_PRIVATE) }
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var logExpanded = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_settings_v2, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val deviceId = DeviceInfo.getOrCreateDeviceId(requireContext())
|
||||
view.findViewById<TextView>(R.id.tvServerUrl).text =
|
||||
"服务器 ${prefs.getString("server_url", "未绑定")}"
|
||||
view.findViewById<TextView>(R.id.tvProjectId).text =
|
||||
"项目 ${prefs.getString("project_id", "未配置")}"
|
||||
view.findViewById<TextView>(R.id.tvDeviceId).text = "设备ID $deviceId"
|
||||
|
||||
// device_id 复制 + 二维码展示(CKB-147 业务绑定辅助)
|
||||
view.findViewById<Button>(R.id.btnCopyDeviceId).setOnClickListener { copyDeviceId(deviceId) }
|
||||
view.findViewById<Button>(R.id.btnShowDeviceQr).setOnClickListener { showDeviceQr(deviceId) }
|
||||
|
||||
// MIUI 保活 checklist(WP-AGENT-01)
|
||||
view.findViewById<Button>(R.id.btnKeepAutoStart).setOnClickListener {
|
||||
// MIUI 自启动无标准 Intent,跳应用详情页让用户手动进「自启动」
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = android.net.Uri.parse("package:${requireContext().packageName}")
|
||||
})
|
||||
}
|
||||
view.findViewById<Button>(R.id.btnKeepBattery).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
view.findViewById<Button>(R.id.btnKeepNotify).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
|
||||
})
|
||||
}
|
||||
view.findViewById<Button>(R.id.btnKeepA11y).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
|
||||
// WP-PMAX-01 Root 隐藏 checklist(量产黄灯必备)
|
||||
view.findViewById<Button>(R.id.btnRootDenyList).setOnClickListener {
|
||||
launchMagisk("请在 Magisk「DenyList」中勾选微信(com.tencent.mm)")
|
||||
}
|
||||
view.findViewById<Button>(R.id.btnRootShamiko).setOnClickListener {
|
||||
launchMagisk("请在 Magisk「模块」中确认 Shamiko 已启用")
|
||||
}
|
||||
view.findViewById<Button>(R.id.btnRootCheckPort).setOnClickListener {
|
||||
scope.launch {
|
||||
val listening = withContext(Dispatchers.IO) {
|
||||
// 27042 = 0x69A2,/proc/net/tcp 端口为十六进制
|
||||
val r = ShellExecutor.execute("cat /proc/net/tcp 2>/dev/null | grep -i ':69A2'")
|
||||
r.success && r.output.isNotBlank() && r.output != "ok"
|
||||
}
|
||||
val msg = if (listening) "⚠ 27042 端口正在监听,建议关闭 Frida 默认端口" else "✓ 27042 端口未监听"
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<Button>(R.id.btnRebind).setOnClickListener {
|
||||
prefs.edit().putBoolean("bound", false).apply()
|
||||
startActivity(Intent(requireContext(), SetupActivity::class.java))
|
||||
requireActivity().finish()
|
||||
}
|
||||
|
||||
view.findViewById<Button>(R.id.btnOpenA11y).setOnClickListener {
|
||||
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
|
||||
view.findViewById<Button>(R.id.btnRestartService).setOnClickListener {
|
||||
startAgentService()
|
||||
Toast.makeText(requireContext(), "服务已重启", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
view.findViewById<Button>(R.id.btnStopService).setOnClickListener {
|
||||
val intent = Intent(requireContext(), AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_STOP
|
||||
}
|
||||
requireContext().startService(intent)
|
||||
Toast.makeText(requireContext(), "服务已停止", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
val logScroll = view.findViewById<ScrollView>(R.id.logScrollView)
|
||||
val tvLog = view.findViewById<TextView>(R.id.tvLogContent)
|
||||
val btnToggle = view.findViewById<Button>(R.id.btnToggleLog)
|
||||
|
||||
btnToggle.setOnClickListener {
|
||||
logExpanded = !logExpanded
|
||||
if (logExpanded) {
|
||||
logScroll.visibility = View.VISIBLE
|
||||
logScroll.layoutParams.height = (300 * resources.displayMetrics.density).toInt()
|
||||
logScroll.requestLayout()
|
||||
btnToggle.text = "收起"
|
||||
val logs = Logger.getRecentLogs(200)
|
||||
tvLog.text = if (logs.isEmpty()) "暂无日志" else logs.joinToString("\n")
|
||||
logScroll.post { logScroll.fullScroll(ScrollView.FOCUS_DOWN) }
|
||||
} else {
|
||||
logScroll.visibility = View.GONE
|
||||
btnToggle.text = "展开"
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<Button>(R.id.btnClearLog).setOnClickListener {
|
||||
Logger.clearBuffer()
|
||||
tvLog.text = "已清空"
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDeviceId(deviceId: String) {
|
||||
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("device_id", deviceId))
|
||||
Toast.makeText(requireContext(), "已复制 device_id", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun showDeviceQr(deviceId: String) {
|
||||
try {
|
||||
// CKB-147:二维码内容为标准 JSON,存客宝扫码即可识别设备归属服务器/项目
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
val projectId = prefs.getString("project_id", "cunkebao") ?: "cunkebao"
|
||||
val qrJson = Gson().toJson(
|
||||
mapOf("device_id" to deviceId, "server" to serverUrl, "project" to projectId)
|
||||
)
|
||||
val size = 512
|
||||
val matrix = MultiFormatWriter().encode(qrJson, BarcodeFormat.QR_CODE, size, size)
|
||||
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
val iv = ImageView(requireContext())
|
||||
iv.setImageBitmap(bmp)
|
||||
val padding = (16 * resources.displayMetrics.density).toInt()
|
||||
iv.setPadding(padding, padding, padding, padding)
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("设备绑定二维码")
|
||||
.setMessage("存客宝扫描此二维码即可完成业务绑定(CKB-147)\n内容:device_id + 服务器 + 项目")
|
||||
.setView(iv)
|
||||
.setPositiveButton("关闭", null)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Logger.e("showDeviceQr failed", e)
|
||||
Toast.makeText(requireContext(), "二维码生成失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun safeStart(intent: Intent) {
|
||||
try {
|
||||
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "无法打开:${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/** WP-PMAX-01 跳转 Magisk App(兼容官方版 + Delta 版,未安装则提示)*/
|
||||
private fun launchMagisk(tip: String) {
|
||||
val pm = requireContext().packageManager
|
||||
// 官方 Magisk: com.topjohnwu.magisk · Magisk Delta: io.github.vvb2060.magisk
|
||||
val magiskPackages = listOf("com.topjohnwu.magisk", "io.github.vvb2060.magisk")
|
||||
val magiskIntent = magiskPackages.firstNotNullOfOrNull { pkg ->
|
||||
pm.getLaunchIntentForPackage(pkg)
|
||||
}
|
||||
if (magiskIntent != null) {
|
||||
startActivity(magiskIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
Toast.makeText(requireContext(), tip, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "未安装 Magisk App", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAgentService() {
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
val projectId = prefs.getString("project_id", "default") ?: "default"
|
||||
val deviceId = DeviceInfo.getOrCreateDeviceId(requireContext())
|
||||
val intent = Intent(requireContext(), AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_START
|
||||
putExtra(AgentForegroundService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(AgentForegroundService.EXTRA_PROJECT_ID, projectId)
|
||||
putExtra(AgentForegroundService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) requireContext().startForegroundService(intent)
|
||||
else requireContext().startService(intent)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.system.cloudservice.BuildConfig
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.engine.ConnectionState
|
||||
import com.system.cloudservice.engine.LanDiscovery
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class SetupActivity : AppCompatActivity() {
|
||||
|
||||
private val prefs by lazy { getSharedPreferences("agent_config", MODE_PRIVATE) }
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
private enum class PendingAction { NONE, START_QR_SCAN }
|
||||
private var pendingAction: PendingAction = PendingAction.NONE
|
||||
|
||||
private lateinit var viewFlipper: ViewFlipper
|
||||
private lateinit var tvStepIndicator: TextView
|
||||
private lateinit var tvNetworkStatus: TextView
|
||||
private lateinit var btnStep1Next: Button
|
||||
private lateinit var tvSetupStatus: TextView
|
||||
private lateinit var btnScan: Button
|
||||
private lateinit var btnManualInput: Button
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var tvSuccessDeviceId: TextView
|
||||
|
||||
private var currentStep = 1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
|
||||
val forceServer = intent?.getStringExtra("server_url")?.trim().orEmpty()
|
||||
val hasForcedConfig = forceServer.isNotEmpty()
|
||||
if (!hasForcedConfig && isAlreadyBound()) { launchMain(); return }
|
||||
|
||||
setContentView(R.layout.activity_setup)
|
||||
|
||||
viewFlipper = findViewById(R.id.viewFlipper)
|
||||
tvStepIndicator = findViewById(R.id.tvStepIndicator)
|
||||
tvNetworkStatus = findViewById(R.id.tvNetworkStatus)
|
||||
btnStep1Next = findViewById(R.id.btnStep1Next)
|
||||
tvSetupStatus = findViewById(R.id.tvSetupStatus)
|
||||
btnScan = findViewById(R.id.btnScan)
|
||||
btnManualInput = findViewById(R.id.btnManualInput)
|
||||
progressBar = findViewById(R.id.progressSetup)
|
||||
tvSuccessDeviceId = findViewById(R.id.tvSuccessDeviceId)
|
||||
|
||||
// 步骤 1:联网检查
|
||||
btnStep1Next.setOnClickListener { goToStep(2) }
|
||||
|
||||
// 步骤 2:保活 checklist(WP-AGENT-01)
|
||||
findViewById<Button>(R.id.btnSetupAutoStart).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
})
|
||||
}
|
||||
findViewById<Button>(R.id.btnSetupBattery).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
findViewById<Button>(R.id.btnSetupNotify).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
})
|
||||
}
|
||||
findViewById<Button>(R.id.btnSetupA11y).setOnClickListener {
|
||||
safeStart(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
findViewById<Button>(R.id.btnStep2Next).setOnClickListener { goToStep(3) }
|
||||
|
||||
// 步骤 3:扫码绑定
|
||||
btnScan.setOnClickListener { ensureCameraThenStartQr() }
|
||||
btnManualInput.setOnClickListener { startLanScan() }
|
||||
|
||||
// 成功页
|
||||
findViewById<Button>(R.id.btnCopySuccess).setOnClickListener { copyDeviceId(tvSuccessDeviceId.text.toString()) }
|
||||
findViewById<Button>(R.id.btnEnterMain).setOnClickListener { launchMain() }
|
||||
|
||||
// adb 注入配置:跳过向导直接绑定
|
||||
if (hasForcedConfig && consumeIntentConfigIfPresent()) return
|
||||
if (consumeIntentConfigIfPresent()) return
|
||||
|
||||
goToStep(1)
|
||||
checkNetwork()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (currentStep == 1 && ::tvNetworkStatus.isInitialized) checkNetwork()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (::tvSetupStatus.isInitialized) consumeIntentConfigIfPresent()
|
||||
}
|
||||
|
||||
override fun onDestroy() { super.onDestroy(); scope.cancel() }
|
||||
|
||||
private fun isAlreadyBound(): Boolean =
|
||||
prefs.getBoolean("bound", false) && prefs.getString("server_url", "").orEmpty().isNotBlank()
|
||||
|
||||
/** 步骤切换:1 联网 / 2 保活 / 3 扫码 / 4 成功 */
|
||||
private fun goToStep(step: Int) {
|
||||
currentStep = step
|
||||
viewFlipper.displayedChild = step - 1
|
||||
tvStepIndicator.text = when (step) {
|
||||
1 -> "1/3 · 联网检查"
|
||||
2 -> "2/3 · 保活权限"
|
||||
3 -> "3/3 · 扫码绑定"
|
||||
else -> "绑定完成"
|
||||
}
|
||||
if (step == 3) autoScanLan()
|
||||
}
|
||||
|
||||
/** 步骤 1 联网检查:WiFi/移动网络可达即放行 */
|
||||
private fun checkNetwork() {
|
||||
scope.launch {
|
||||
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork
|
||||
val caps = cm.getNetworkCapabilities(network)
|
||||
val hasNetwork = caps != null && (
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
)
|
||||
if (hasNetwork) {
|
||||
tvNetworkStatus.text = "网络已连接 ✓\n请点击「下一步」继续"
|
||||
btnStep1Next.isEnabled = true
|
||||
} else {
|
||||
tvNetworkStatus.text = "未检测到网络连接\n请连接 WiFi 后返回本页"
|
||||
btnStep1Next.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureCameraThenStartQr() {
|
||||
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) { startQrScan(); return }
|
||||
pendingAction = PendingAction.START_QR_SCAN
|
||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 2001)
|
||||
}
|
||||
|
||||
private fun startQrScan() {
|
||||
@Suppress("DEPRECATION")
|
||||
IntentIntegrator(this)
|
||||
.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
.setPrompt("对准服务器二维码")
|
||||
.setCameraId(0)
|
||||
.setBeepEnabled(false)
|
||||
.setBarcodeImageEnabled(false)
|
||||
.setOrientationLocked(true)
|
||||
.initiateScan()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != 2001) return
|
||||
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) {
|
||||
tvSetupStatus.text = "需要相机权限才能扫码绑定"
|
||||
pendingAction = PendingAction.NONE
|
||||
return
|
||||
}
|
||||
when (pendingAction) {
|
||||
PendingAction.START_QR_SCAN -> startQrScan()
|
||||
PendingAction.NONE -> {}
|
||||
}
|
||||
pendingAction = PendingAction.NONE
|
||||
}
|
||||
|
||||
private fun autoScanLan() {
|
||||
tvSetupStatus.text = "正在搜索局域网服务器..."
|
||||
progressBar.visibility = View.VISIBLE
|
||||
scope.launch {
|
||||
try {
|
||||
val localIp = withContext(Dispatchers.IO) { getLocalIp() }
|
||||
val servers = LanDiscovery.discover(localIp)
|
||||
if (servers.isNotEmpty()) {
|
||||
val best = servers.first()
|
||||
tvSetupStatus.text = "发现服务器: ${best.name}"
|
||||
handleConfig(mapOf("server" to best.wsUrl, "project" to "cunkebao"))
|
||||
} else {
|
||||
tvSetupStatus.text = "未发现局域网服务器\n请扫码绑定"
|
||||
progressBar.visibility = View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("LAN auto scan failed", e)
|
||||
tvSetupStatus.text = "请扫码绑定服务器"
|
||||
progressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLanScan() {
|
||||
tvSetupStatus.text = "搜索局域网服务器中..."
|
||||
setLoading(true)
|
||||
scope.launch {
|
||||
try {
|
||||
val localIp = withContext(Dispatchers.IO) { getLocalIp() }
|
||||
val servers = LanDiscovery.discover(localIp)
|
||||
if (servers.isEmpty()) {
|
||||
tvSetupStatus.text = "未找到服务器,请扫码绑定"
|
||||
setLoading(false)
|
||||
return@launch
|
||||
}
|
||||
if (servers.size == 1) {
|
||||
handleConfig(mapOf("server" to servers[0].wsUrl, "project" to "cunkebao"))
|
||||
} else {
|
||||
showServerPicker(servers)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("LAN scan failed", e)
|
||||
tvSetupStatus.text = "搜索失败,请扫码绑定"
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showServerPicker(servers: List<com.system.cloudservice.engine.DiscoveredServer>) {
|
||||
setLoading(false)
|
||||
val names = servers.map { it.name }.toTypedArray()
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle("发现 ${servers.size} 个服务器")
|
||||
.setItems(names) { _, which ->
|
||||
handleConfig(mapOf("server" to servers[which].wsUrl, "project" to "cunkebao"))
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getLocalIp(): String? {
|
||||
try {
|
||||
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
|
||||
while (interfaces.hasMoreElements()) {
|
||||
val ni = interfaces.nextElement()
|
||||
if (!ni.isUp || ni.isLoopback) continue
|
||||
val addrs = ni.inetAddresses
|
||||
while (addrs.hasMoreElements()) {
|
||||
val addr = addrs.nextElement()
|
||||
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) return addr.hostAddress
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
|
||||
if (result?.contents != null) handleQrResult(result.contents)
|
||||
else super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private fun handleQrResult(json: String) {
|
||||
try {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
val config: Map<String, String> = Gson().fromJson(json, type)
|
||||
handleConfig(config)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("QR parse failed", e)
|
||||
tvSetupStatus.text = "二维码格式错误,请重试"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持通过 adb am start 直接注入配置,避免手动扫码/局域网发现。
|
||||
* 示例:
|
||||
* adb shell am start -n com.system.cloudservice/.ui.SetupActivity \
|
||||
* --es server_url ws://127.0.0.1:8899 --es project_id cunkebao \
|
||||
* --es pairing_token '<token>' --es device_id xgfe65eimrrofyws --ei frida_port 15715
|
||||
*/
|
||||
private fun consumeIntentConfigIfPresent(): Boolean {
|
||||
val server = intent?.getStringExtra("server_url")?.trim().orEmpty()
|
||||
if (server.isEmpty()) return false
|
||||
val project = intent?.getStringExtra("project_id")?.trim().takeUnless { it.isNullOrEmpty() } ?: "cunkebao"
|
||||
val pwa = intent?.getStringExtra("pwa_url")?.trim().takeUnless { it.isNullOrEmpty() } ?: BuildConfig.PWA_URL
|
||||
val cfg = mutableMapOf("server" to server, "project" to project, "pwa" to pwa)
|
||||
intent?.getStringExtra("pairing_token")?.trim()?.takeIf { it.isNotEmpty() }?.let { token ->
|
||||
cfg["pairing_token"] = token
|
||||
}
|
||||
intent?.getStringExtra("device_id")?.trim()?.takeIf { it.isNotEmpty() }?.let { deviceId ->
|
||||
cfg["device_id"] = deviceId
|
||||
}
|
||||
if (intent?.hasExtra("frida_port") == true) {
|
||||
cfg["frida_port"] = intent!!.getIntExtra("frida_port", 27042).toString()
|
||||
}
|
||||
if (::tvSetupStatus.isInitialized) tvSetupStatus.text = "收到外部配置,正在绑定..."
|
||||
handleConfig(cfg)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleConfig(config: Map<String, String>) {
|
||||
setLoading(true)
|
||||
tvSetupStatus.text = "连接中..."
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val serverUrl = config["server"] ?: throw IllegalArgumentException("缺少服务器地址")
|
||||
val projectId = config["project"] ?: "cunkebao"
|
||||
val pwaUrl = config["pwa"] ?: BuildConfig.PWA_URL
|
||||
val deviceId = config["device_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: DeviceInfo.getOrCreateDeviceId(this@SetupActivity)
|
||||
|
||||
prefs.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("project_id", projectId)
|
||||
.putString("device_id", deviceId)
|
||||
.putString("pwa_url", pwaUrl)
|
||||
.putBoolean("auto_connect", true)
|
||||
.putBoolean("bound", true)
|
||||
.apply()
|
||||
|
||||
config["ai_api_key"]?.trim()?.takeIf { it.isNotEmpty() }?.let { key ->
|
||||
prefs.edit().putString("ai_api_key", key).putBoolean("ai_enabled", true).apply()
|
||||
}
|
||||
config["ai_api_url"]?.trim()?.takeIf { it.isNotEmpty() }?.let { url ->
|
||||
prefs.edit().putString("ai_api_url", url).apply()
|
||||
}
|
||||
config["public_servers"]?.trim()?.takeIf { it.isNotEmpty() }?.let { ps ->
|
||||
prefs.edit().putString("public_servers", ps).apply()
|
||||
}
|
||||
config["pairing_token"]?.trim()?.takeIf { it.isNotEmpty() }?.let { token ->
|
||||
prefs.edit().putString("pairing_token", token).apply()
|
||||
}
|
||||
config["auto_discover"]?.trim()?.takeIf { it.isNotEmpty() }?.let { ad ->
|
||||
prefs.edit().putBoolean("auto_discover", ad == "1" || ad.equals("true", true)).apply()
|
||||
}
|
||||
config["frida_port"]?.trim()?.toIntOrNull()?.takeIf { it in 1025..65535 }?.let { port ->
|
||||
prefs.edit().putInt("frida_port", port).apply()
|
||||
}
|
||||
|
||||
startAgentService(serverUrl, projectId, deviceId)
|
||||
tvSuccessDeviceId.text = DeviceInfo.deviceIdMd5(this@SetupActivity)
|
||||
// BIND-04 连接阶段实时映射(复用 DashboardFragment 人话映射)
|
||||
var elapsed = 0
|
||||
while (isActive && elapsed < 15_000) {
|
||||
delay(500)
|
||||
elapsed += 500
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
val connState = engine?.wsManager?.state?.value
|
||||
tvSetupStatus.text = "连接中 · ${stageText(engine?.getConnectStage())}..."
|
||||
if (connState == ConnectionState.CONNECTED) { goToStep(4); return@launch }
|
||||
}
|
||||
tvSetupStatus.text = "连接超时,请检查服务器\n可返回首页查看连接状态"
|
||||
setLoading(false)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Bind failed", e)
|
||||
tvSetupStatus.text = "连接失败,请重试"
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoading(loading: Boolean) {
|
||||
btnScan.isEnabled = !loading
|
||||
btnManualInput.isEnabled = !loading
|
||||
progressBar.visibility = if (loading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun copyDeviceId(deviceId: String) {
|
||||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("device_id_md5", deviceId))
|
||||
Toast.makeText(this, "已复制设备 MD5", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun safeStart(intent: Intent) {
|
||||
try { startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
catch (e: Exception) { Toast.makeText(this, "无法打开:${e.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
private fun startAgentService(serverUrl: String, projectId: String, deviceId: String) {
|
||||
val intent = Intent(this, AgentForegroundService::class.java).apply {
|
||||
action = AgentForegroundService.ACTION_START
|
||||
putExtra(AgentForegroundService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(AgentForegroundService.EXTRA_PROJECT_ID, projectId)
|
||||
putExtra(AgentForegroundService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent)
|
||||
else startService(intent)
|
||||
}
|
||||
|
||||
private fun launchMain() { startActivity(Intent(this, MainActivity::class.java)); finish() }
|
||||
|
||||
/** BIND-04 connect_stage 人话映射(与 DashboardFragment 一致) */
|
||||
private fun stageText(raw: String?): String = when (raw) {
|
||||
"primary" -> "主服务器"
|
||||
"lan" -> "局域网服务器"
|
||||
"public" -> "公网备用"
|
||||
null -> "初始化"
|
||||
else -> raw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.engine.ConnectionState
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class StatusFragment : Fragment() {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
private lateinit var tvConnection: TextView
|
||||
private lateinit var tvRoot: TextView
|
||||
private lateinit var tvFrida: TextView
|
||||
private lateinit var tvA11y: TextView
|
||||
private lateinit var tvAiBrain: TextView
|
||||
private lateinit var tvAntiBan: TextView
|
||||
private lateinit var tvDevice: TextView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_status, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
tvConnection = view.findViewById(R.id.tvConnection)
|
||||
tvRoot = view.findViewById(R.id.tvRoot)
|
||||
tvFrida = view.findViewById(R.id.tvFrida)
|
||||
tvA11y = view.findViewById(R.id.tvA11y)
|
||||
tvAiBrain = view.findViewById(R.id.tvAiBrain)
|
||||
tvAntiBan = view.findViewById(R.id.tvAntiBan)
|
||||
tvDevice = view.findViewById(R.id.tvDevice)
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshJob = scope.launch {
|
||||
while (isActive) {
|
||||
refresh()
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
refreshJob?.cancel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val svc = AgentForegroundService.instance
|
||||
val engine = svc?.engine
|
||||
|
||||
val connState = engine?.wsManager?.state?.value ?: ConnectionState.DISCONNECTED
|
||||
tvConnection.text = when (connState) {
|
||||
ConnectionState.CONNECTED -> "✅ WebSocket 已连接"
|
||||
ConnectionState.CONNECTING -> "🔄 正在连接..."
|
||||
ConnectionState.DISCONNECTED -> "❌ 未连接"
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val hasRoot = ShellExecutor.hasRoot()
|
||||
withContext(Dispatchers.Main) { tvRoot.text = if (hasRoot) "✅ Root 可用" else "⬜ 无 Root" }
|
||||
}
|
||||
|
||||
val fridaReady = engine?.isFridaReady() == true
|
||||
val hookAttached = engine?.isWechatHookAttached() == true
|
||||
tvFrida.text = when {
|
||||
engine == null -> "⬜ Frida 未启用"
|
||||
hookAttached -> "✅ 微信 Hook 已注入"
|
||||
fridaReady -> "🟡 Frida 服务就绪 · 微信 Hook 未注入"
|
||||
engine.hasFridaBridge() -> "🔄 Frida 启动中…"
|
||||
else -> "⬜ Frida 未启用(无 Root 或未拉起)"
|
||||
}
|
||||
|
||||
tvA11y.text = if (isAdded && AgentAccessibilityService.isEnabled(requireContext())) {
|
||||
"✅ 无障碍服务 已启用"
|
||||
} else {
|
||||
"⬜ 无障碍服务 未启用"
|
||||
}
|
||||
|
||||
tvAiBrain.text = "⬜ AI Brain 状态"
|
||||
tvAntiBan.text = "✅ 防封模块 运行中"
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val info = DeviceInfo.collectQuick(requireContext())
|
||||
withContext(Dispatchers.Main) {
|
||||
tvDevice.text = buildString {
|
||||
append("电量: ${info["battery_level"]}%")
|
||||
if (info["battery_charging"] == true) append(" ⚡充电中")
|
||||
append("\n内存: ${info["memory_usage_pct"]}%")
|
||||
append(" 存储余: ${info["storage_free_mb"]}MB")
|
||||
append("\n网络: ${info["network_type"]}")
|
||||
append(" 微信: ${if (info["wechat_running"] == true) "运行中" else "未运行"}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.system.cloudservice.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.system.cloudservice.R
|
||||
import com.system.cloudservice.service.AgentForegroundService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class WechatFragment : Fragment() {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val prefs by lazy { requireContext().getSharedPreferences("agent_config", 0) }
|
||||
|
||||
/** WP-WX-04 相册选图加友:选图后转缓存文件交给 add_friend_from_image skill */
|
||||
private val pickImageLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { handlePickedImage(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_wechat, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
view.findViewById<Button>(R.id.btnOpenWechat).setOnClickListener { executeAction("open_app", mapOf("package" to "com.tencent.mm")) }
|
||||
view.findViewById<Button>(R.id.btnGetContacts).setOnClickListener { executeAction("get_contacts", emptyMap(), "wechat") }
|
||||
view.findViewById<Button>(R.id.btnGetMessages).setOnClickListener { executeAction("get_messages", emptyMap(), "wechat") }
|
||||
view.findViewById<Button>(R.id.btnUnblockAssistant).setOnClickListener { triggerUnblockAssistant() }
|
||||
view.findViewById<Button>(R.id.btnScanAddFriend).setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_PICK).apply { type = "image/*" }
|
||||
pickImageLauncher.launch(intent)
|
||||
}
|
||||
refresh(view)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
view?.let { refresh(it) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun refresh(view: View) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val info = DeviceInfo.collectQuick(requireContext())
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
val isConnected = engine?.wsManager?.isConnected ?: false
|
||||
val hookReady = engine?.isWechatHookAttached() == true
|
||||
val antiBanStatus = engine?.getAntiBanStatus()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!isAdded) return@withContext
|
||||
|
||||
val wechatRunning = info["wechat_running"] == true
|
||||
|
||||
val tvRunning = view.findViewById<TextView>(R.id.tvWxRunning)
|
||||
tvRunning.text = if (wechatRunning) "运行中" else "未运行"
|
||||
tvRunning.setTextColor(resources.getColor(
|
||||
if (wechatRunning) R.color.accent_green else R.color.accent_red, null
|
||||
))
|
||||
tvRunning.setBackgroundResource(
|
||||
if (wechatRunning) R.drawable.bg_pill_online else R.drawable.bg_pill_offline
|
||||
)
|
||||
|
||||
view.findViewById<TextView>(R.id.tvWxVersion).text =
|
||||
"版本 ${info["wechat_version"] ?: "未检测到"}"
|
||||
|
||||
view.findViewById<TextView>(R.id.tvHookStatus).text =
|
||||
"Hook ${if (hookReady) "已注入" else "未注入"}"
|
||||
|
||||
val available = hookReady && isConnected
|
||||
view.findViewById<TextView>(R.id.tvMsgCapability).text =
|
||||
"消息收发 ${if (available) "✓ 可用" else "—"}"
|
||||
view.findViewById<TextView>(R.id.tvContactCapability).text =
|
||||
"通讯录 ${if (available) "✓ 可用" else "—"}"
|
||||
view.findViewById<TextView>(R.id.tvMomentCapability).text =
|
||||
"朋友圈 ${if (available) "✓ 可用" else "—"}"
|
||||
|
||||
view.findViewById<Button>(R.id.btnOpenWechat).isEnabled = true
|
||||
view.findViewById<Button>(R.id.btnGetContacts).isEnabled = available
|
||||
view.findViewById<Button>(R.id.btnGetMessages).isEnabled = available
|
||||
|
||||
updateAntiBanUI(view, antiBanStatus, engine != null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AB-02~06 防封 runtime 信号渲染 + AB-02/03 风险 Banner 显隐 */
|
||||
private fun updateAntiBanUI(view: View, status: Map<String, Any?>?, engineRunning: Boolean) {
|
||||
val tvEngine = view.findViewById<TextView>(R.id.tvAntiBanStatus)
|
||||
val tvRisk = view.findViewById<TextView>(R.id.tvRiskLevel)
|
||||
val tvQuota = view.findViewById<TextView>(R.id.tvQuota)
|
||||
val tvNurture = view.findViewById<TextView>(R.id.tvNurturePhase)
|
||||
val banner = view.findViewById<android.view.View>(R.id.bannerCircuitBreaker)
|
||||
val tvBannerTitle = view.findViewById<TextView>(R.id.tvBannerTitle)
|
||||
val tvCooldown = view.findViewById<TextView>(R.id.tvCooldownRemaining)
|
||||
|
||||
if (status == null) {
|
||||
tvEngine.text = "防封引擎 ${if (engineRunning) "初始化中" else "服务未运行"}"
|
||||
tvRisk.text = "风险等级 —"
|
||||
tvQuota.text = "今日配额 —/min · —/hr"
|
||||
tvNurture.text = "暖机阶段 —"
|
||||
banner.visibility = android.view.View.GONE
|
||||
return
|
||||
}
|
||||
|
||||
val risk = status["risk_sentinel"] as? Map<*, *> ?: emptyMap<String, Any?>()
|
||||
val nurture = status["nurture"] as? Map<*, *> ?: emptyMap<String, Any?>()
|
||||
val level = (risk["level"] as? Number)?.toInt() ?: 0
|
||||
val levelName = risk["level_name"]?.toString() ?: "未知"
|
||||
val minuteCount = (risk["minute_count"] as? Number)?.toInt() ?: 0
|
||||
val hourCount = (risk["hour_count"] as? Number)?.toInt() ?: 0
|
||||
val cooldownMs = (risk["cooldown_remaining_ms"] as? Number)?.toLong() ?: 0L
|
||||
val consecutiveFailures = (risk["consecutive_failures"] as? Number)?.toInt() ?: 0
|
||||
val phase = nurture["phase"]?.toString() ?: "—"
|
||||
val accountAgeDays = (nurture["account_age_days"] as? Number)?.toInt() ?: 0
|
||||
|
||||
tvEngine.text = "防封引擎 已启用"
|
||||
tvRisk.text = "风险等级 $levelName"
|
||||
tvRisk.setTextColor(resources.getColor(
|
||||
when (level) {
|
||||
0 -> R.color.accent_green
|
||||
1 -> R.color.accent_blue
|
||||
2 -> R.color.accent_orange
|
||||
else -> R.color.accent_red
|
||||
}, null
|
||||
))
|
||||
tvQuota.text = "今日配额 $minuteCount/min · $hourCount/hr"
|
||||
tvNurture.text = "暖机阶段 $phase(账号 $accountAgeDays 天)"
|
||||
|
||||
// AB-02 熔断红条 / AB-03 长暂停黄条
|
||||
when {
|
||||
level >= 3 || cooldownMs > 0 -> {
|
||||
banner.visibility = android.view.View.VISIBLE
|
||||
banner.setBackgroundResource(R.drawable.bg_pill_offline)
|
||||
tvBannerTitle.text = "已熔断,自动化已暂停"
|
||||
tvBannerTitle.setTextColor(resources.getColor(R.color.accent_red, null))
|
||||
tvCooldown.text = if (cooldownMs > 0) "剩余 ${cooldownMs / 1000}s" else ""
|
||||
tvCooldown.setTextColor(resources.getColor(R.color.accent_red, null))
|
||||
}
|
||||
level == 2 -> {
|
||||
banner.visibility = android.view.View.VISIBLE
|
||||
banner.setBackgroundResource(R.drawable.bg_pill_connecting)
|
||||
tvBannerTitle.text = "风险较高,已降速"
|
||||
tvBannerTitle.setTextColor(resources.getColor(R.color.accent_orange, null))
|
||||
tvCooldown.text = ""
|
||||
}
|
||||
else -> banner.visibility = android.view.View.GONE
|
||||
}
|
||||
|
||||
// 防封运行时卡点击 → 详情弹窗
|
||||
val cardAntiBan = view.findViewById<android.view.View>(R.id.cardAntiBan)
|
||||
cardAntiBan?.setOnClickListener {
|
||||
showAntiBanDetail(level, levelName, minuteCount, hourCount, cooldownMs, consecutiveFailures, phase, accountAgeDays)
|
||||
}
|
||||
}
|
||||
|
||||
/** §十·五 防封详情弹窗:今日发送数 · 连续失败 · 冷却 · 暖机 · 打开 hub 防封页 */
|
||||
private fun showAntiBanDetail(
|
||||
level: Int, levelName: String,
|
||||
minuteCount: Int, hourCount: Int,
|
||||
cooldownMs: Long, consecutiveFailures: Int,
|
||||
phase: String, accountAgeDays: Int,
|
||||
) {
|
||||
val msg = buildString {
|
||||
append("风险等级 $levelName(L$level)\n")
|
||||
append("今日配额 $minuteCount/min · $hourCount/hr\n")
|
||||
append("连续失败 $consecutiveFailures 次\n")
|
||||
if (cooldownMs > 0) append("冷却剩余 ${cooldownMs / 1000}s\n")
|
||||
append("暖机阶段 $phase(账号 $accountAgeDays 天)")
|
||||
}
|
||||
androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("防封详情")
|
||||
.setMessage(msg)
|
||||
.setPositiveButton("关闭", null)
|
||||
.setNeutralButton("打开 hub 防封页") { _, _ ->
|
||||
val pwaUrl = prefs.getString("pwa_url", "") ?: ""
|
||||
if (pwaUrl.isNotEmpty()) {
|
||||
startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(pwaUrl)))
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "未配置 hub 地址", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
/** WP-WX-03 解封助手入口:服务端 SkillExecutor 不支持时返回 503,UI 诚实提示 */
|
||||
private fun triggerUnblockAssistant() {
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
if (engine == null) {
|
||||
Toast.makeText(requireContext(), "服务未运行,无法启动解封助手", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (!engine.isFridaReady()) {
|
||||
Toast.makeText(requireContext(), "Frida 未注入,解封助手需 Hook 通道在线", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
Toast.makeText(requireContext(), "正在启动解封助手...", Toast.LENGTH_SHORT).show()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
engine.executeLocal(
|
||||
"unblock_via_customer_service",
|
||||
mapOf("reason" to "manual_trigger_from_apk"),
|
||||
"wechat",
|
||||
)
|
||||
}
|
||||
val code = result["code"]?.toString() ?: "-"
|
||||
val msg = result["message"]?.toString().orEmpty()
|
||||
val summary = "解封助手 code=$code · ${msg.ifEmpty { "已触发" }}"
|
||||
view?.findViewById<TextView>(R.id.tvWechatResult)?.text = summary
|
||||
Toast.makeText(requireContext(), summary, Toast.LENGTH_LONG).show()
|
||||
} catch (e: Exception) {
|
||||
Logger.e("UnblockAssistant error", e)
|
||||
Toast.makeText(requireContext(), "解封助手调用失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** WP-WX-04 二维码加友:相册选图 → 缓存文件 → add_friend_from_image skill(Frida 未就绪返回 503) */
|
||||
private fun handlePickedImage(uri: Uri) {
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
if (engine == null) {
|
||||
Toast.makeText(requireContext(), "服务未运行,无法识别二维码", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (!engine.isFridaReady()) {
|
||||
Toast.makeText(requireContext(), "Frida 未注入,二维码加友需 Hook 通道在线", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
Toast.makeText(requireContext(), "正在识别二维码并加好友...", Toast.LENGTH_SHORT).show()
|
||||
val tempFile = withContext(Dispatchers.IO) {
|
||||
requireContext().contentResolver.openInputStream(uri)?.use { input ->
|
||||
java.io.File(requireContext().cacheDir, "temp_qr_${System.currentTimeMillis()}.png").apply {
|
||||
outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
} ?: throw java.io.IOException("无法读取图片")
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
engine.executeLocal(
|
||||
"add_friend_from_image",
|
||||
mapOf("image_path" to tempFile.absolutePath),
|
||||
"wechat",
|
||||
)
|
||||
}
|
||||
val code = result["code"]?.toString() ?: "-"
|
||||
val msg = result["message"]?.toString().orEmpty()
|
||||
val summary = "二维码加友 code=$code · ${msg.ifEmpty { "已触发" }}"
|
||||
view?.findViewById<TextView>(R.id.tvWechatResult)?.text = summary
|
||||
Toast.makeText(requireContext(), summary, Toast.LENGTH_LONG).show()
|
||||
tempFile.delete()
|
||||
} catch (e: Exception) {
|
||||
Logger.e("ScanAddFriend error", e)
|
||||
Toast.makeText(requireContext(), "二维码加友失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeAction(action: String, params: Map<String, Any?>, script: String? = null) {
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
if (engine == null) {
|
||||
Toast.makeText(requireContext(), "服务未运行", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
Toast.makeText(requireContext(), "执行中...", Toast.LENGTH_SHORT).show()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
AgentForegroundService.instance?.engine?.executeLocal(action, params, script)
|
||||
}
|
||||
val summary = summarizeResult(action, result)
|
||||
view?.findViewById<TextView>(R.id.tvWechatResult)?.text = summary
|
||||
Toast.makeText(requireContext(), summary, Toast.LENGTH_SHORT).show()
|
||||
delay(1000)
|
||||
view?.let { refresh(it) }
|
||||
} catch (e: Exception) {
|
||||
Logger.e("WechatFragment action error", e)
|
||||
Toast.makeText(requireContext(), "执行失败: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun summarizeResult(action: String, result: Map<String, Any?>?): String {
|
||||
if (result == null) return "$action 未返回"
|
||||
val code = result["code"]?.toString() ?: "-"
|
||||
val data = result["data"] as? Map<*, *> ?: result
|
||||
val success = data["success"] == true || code == "200"
|
||||
val count = when {
|
||||
data["contacts"] is List<*> -> (data["contacts"] as List<*>).size
|
||||
data["messages"] is List<*> -> (data["messages"] as List<*>).size
|
||||
data["moments"] is List<*> -> (data["moments"] as List<*>).size
|
||||
else -> null
|
||||
}
|
||||
val suffix = count?.let { ",数量 $it" } ?: ""
|
||||
val err = data["error"]?.toString()?.takeIf { it.isNotBlank() }
|
||||
return if (success) "$action 完成$suffix" else "$action 失败:${err ?: code}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.system.cloudservice.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object DeviceInfo {
|
||||
|
||||
fun isWeChatInstalled(context: Context): Boolean =
|
||||
context.packageManager.getLaunchIntentForPackage("com.tencent.mm") != null
|
||||
|
||||
fun getWeChatVersion(context: Context): String = try {
|
||||
context.packageManager.getPackageInfo("com.tencent.mm", 0).versionName?.trim().orEmpty()
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
fun getOrCreateDeviceId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
|
||||
prefs.getString("device_id", "")?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
val generated = getAochuangCompatibleId(context)
|
||||
prefs.edit().putString("device_id", generated).apply()
|
||||
return generated
|
||||
}
|
||||
|
||||
fun getAochuangCompatibleId(context: Context): String {
|
||||
val androidId = getAndroidId(context)
|
||||
return if (androidId.isNotEmpty()) md5(androidId)
|
||||
else "device_${Build.MODEL.replace(" ", "_")}_${Build.VERSION.SDK_INT}"
|
||||
}
|
||||
|
||||
fun deviceIdMd5(context: Context): String = md5(getOrCreateDeviceId(context))
|
||||
|
||||
fun getAndroidId(context: Context): String = try {
|
||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)?.trim().orEmpty()
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
fun getSerial(): String = try {
|
||||
Runtime.getRuntime().exec(arrayOf("getprop", "ro.serialno"))
|
||||
.inputStream.bufferedReader().readText().trim()
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
fun collectFull(context: Context): Map<String, Any?> {
|
||||
val pm = context.packageManager
|
||||
val pkgInfo = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null }
|
||||
return mapOf(
|
||||
"device_id" to getOrCreateDeviceId(context),
|
||||
"model" to Build.MODEL,
|
||||
"brand" to Build.BRAND,
|
||||
"manufacturer" to Build.MANUFACTURER,
|
||||
"android_version" to Build.VERSION.RELEASE,
|
||||
"sdk_version" to Build.VERSION.SDK_INT,
|
||||
"cpu_abi" to Build.SUPPORTED_ABIS.joinToString(","),
|
||||
"screen_width" to context.resources.displayMetrics.widthPixels,
|
||||
"screen_height" to context.resources.displayMetrics.heightPixels,
|
||||
"app_version" to (pkgInfo?.versionName ?: "unknown"),
|
||||
"app_version_code" to (pkgInfo?.longVersionCode ?: -1),
|
||||
"serial" to getSerial(),
|
||||
"device_id_md5" to deviceIdMd5(context),
|
||||
"wechat_version" to getWeChatVersion(context),
|
||||
)
|
||||
}
|
||||
|
||||
fun collectQuick(context: Context): Map<String, Any?> {
|
||||
val bm = context.getSystemService(Context.BATTERY_SERVICE) as? android.os.BatteryManager
|
||||
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? android.app.ActivityManager
|
||||
val mi = android.app.ActivityManager.MemoryInfo().also { am?.getMemoryInfo(it) }
|
||||
val total = mi.totalMem.toDouble()
|
||||
val avail = mi.availMem.toDouble()
|
||||
val memPct = if (total > 0) ((total - avail) / total * 100) else 0.0
|
||||
val stat = android.os.StatFs(android.os.Environment.getDataDirectory().path)
|
||||
val storageFree = (stat.availableBlocksLong * stat.blockSizeLong) / (1024 * 1024)
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager
|
||||
val netType = try {
|
||||
val nc = cm?.getNetworkCapabilities(cm.activeNetwork)
|
||||
when {
|
||||
nc == null -> "none"
|
||||
nc.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
nc.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
else -> "other"
|
||||
}
|
||||
} catch (_: Exception) { "unknown" }
|
||||
|
||||
return mapOf(
|
||||
"battery_level" to (bm?.getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1),
|
||||
"battery_charging" to (bm?.isCharging ?: false),
|
||||
"memory_usage_pct" to (Math.round(memPct * 10.0) / 10.0),
|
||||
"storage_free_mb" to storageFree.toInt(),
|
||||
"screen_on" to (pm?.isInteractive ?: false),
|
||||
"network_type" to netType,
|
||||
"wechat_running" to (am?.runningAppProcesses?.any { it.processName == "com.tencent.mm" } ?: false),
|
||||
"wechat_version" to getWeChatVersion(context),
|
||||
"wxid" to getWxid(context),
|
||||
"uptime_sec" to (android.os.SystemClock.elapsedRealtime() / 1000).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/** WP-CKB-143 读取微信 wxid(优先读 APP files 目录缓存,su 兜底)*/
|
||||
private fun getWxid(context: Context): String {
|
||||
// 优先读 APP files 目录的 wxid_cache.txt(由 adb su / Frida 定期写入)
|
||||
try {
|
||||
val f = java.io.File(context.filesDir, "wxid_cache.txt")
|
||||
if (f.exists()) {
|
||||
val wxid = f.readText().trim()
|
||||
if (wxid.startsWith("wxid_")) return wxid
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// 兜底:su 直接读 SharedPreferences(可能受 SELinux category 限制)
|
||||
val suBins = listOf("/product/bin/su", "/system/bin/su", "/system/xbin/su", "su")
|
||||
val cmd = "cat /data/data/com.tencent.mm/shared_prefs/com.tencent.mm_preferences.xml"
|
||||
for (su in suBins) {
|
||||
try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf(su, "-c", cmd))
|
||||
if (p.waitFor(10, TimeUnit.SECONDS)) {
|
||||
val out = p.inputStream.bufferedReader().readText()
|
||||
if (p.exitValue() == 0 && out.isNotEmpty()) {
|
||||
return Regex("wxid_[a-zA-Z0-9_]+").find(out)?.value ?: ""
|
||||
}
|
||||
} else {
|
||||
p.destroyForcibly()
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun md5(input: String): String {
|
||||
val digest = MessageDigest.getInstance("MD5").digest(input.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.system.cloudservice.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object Logger {
|
||||
|
||||
private const val TAG = "AiAgent"
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
private var fileWriter: FileWriter? = null
|
||||
private var logFile: File? = null
|
||||
|
||||
private val buffer = LinkedList<String>()
|
||||
private const val BUFFER_MAX = 1000
|
||||
private val bufferLock = Any()
|
||||
|
||||
fun init(context: Context) {
|
||||
try {
|
||||
val dir = File(context.filesDir, "logs")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val today = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
|
||||
logFile = File(dir, "agent_$today.log")
|
||||
fileWriter = FileWriter(logFile, true)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun write(level: String, msg: String, t: Throwable? = null) {
|
||||
val line = "${dateFormat.format(Date())} [$level] $msg"
|
||||
synchronized(bufferLock) {
|
||||
buffer.addLast(line)
|
||||
if (t != null) buffer.addLast(t.stackTraceToString())
|
||||
while (buffer.size > BUFFER_MAX) buffer.removeFirst()
|
||||
}
|
||||
try {
|
||||
fileWriter?.apply {
|
||||
append(line + "\n")
|
||||
if (t != null) append(t.stackTraceToString() + "\n")
|
||||
flush()
|
||||
}
|
||||
logFile?.let {
|
||||
if (it.length() > 10 * 1024 * 1024) rotate()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
try {
|
||||
fileWriter?.close()
|
||||
logFile?.let { File("${it.absolutePath}.old").also { o -> it.renameTo(o) } }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun d(msg: String, t: Throwable? = null) { Log.d(TAG, msg, t); write("D", msg, t) }
|
||||
fun i(msg: String, t: Throwable? = null) { Log.i(TAG, msg, t); write("I", msg, t) }
|
||||
fun w(msg: String, t: Throwable? = null) { Log.w(TAG, msg, t); write("W", msg, t) }
|
||||
fun e(msg: String, t: Throwable? = null) { Log.e(TAG, msg, t); write("E", msg, t) }
|
||||
|
||||
fun getRecentLogs(maxLines: Int = 200): List<String> {
|
||||
synchronized(bufferLock) {
|
||||
val start = if (buffer.size > maxLines) buffer.size - maxLines else 0
|
||||
return buffer.subList(start, buffer.size).toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBuffer() {
|
||||
synchronized(bufferLock) { buffer.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.system.cloudservice.util
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ShellExecutor {
|
||||
|
||||
data class Result(val success: Boolean, val output: String, val isRoot: Boolean = false)
|
||||
|
||||
private val suBins = listOf("/product/bin/su", "/system/bin/su", "/system/xbin/su", "su")
|
||||
|
||||
fun execute(command: String, timeoutSec: Long = 15): Result {
|
||||
// 1) try su first (5s timeout — Magisk may show prompt)
|
||||
for (su in suBins) {
|
||||
runWithTimeout(arrayOf(su, "-c", command), 5)?.let { (code, out, err) ->
|
||||
if (code == 0) return Result(true, out.ifEmpty { "ok" }, isRoot = true)
|
||||
if (err.contains("not found", ignoreCase = true)) return@let
|
||||
}
|
||||
}
|
||||
// 2) fallback to sh
|
||||
runWithTimeout(arrayOf("sh", "-c", command), timeoutSec)?.let { (code, out, err) ->
|
||||
return if (code == 0) Result(true, out.ifEmpty { "ok" })
|
||||
else Result(false, err.ifEmpty { "exit $code" })
|
||||
}
|
||||
return Result(false, "timeout")
|
||||
}
|
||||
|
||||
fun hasRoot(): Boolean {
|
||||
for (su in suBins) {
|
||||
val ok = runWithTimeout(arrayOf(su, "-c", "id"), 10)?.let { (code, out, err) ->
|
||||
code == 0 && (out.contains("uid=0") || out.contains("uid=0(")) &&
|
||||
!err.contains("denied", ignoreCase = true)
|
||||
} ?: false
|
||||
if (ok) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun runWithTimeout(
|
||||
cmd: Array<String>, timeoutSec: Long
|
||||
): Triple<Int, String, String>? {
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(cmd)
|
||||
if (!p.waitFor(timeoutSec, TimeUnit.SECONDS)) {
|
||||
p.destroyForcibly(); return null
|
||||
}
|
||||
Triple(
|
||||
p.exitValue(),
|
||||
p.inputStream.bufferedReader().readText().trim(),
|
||||
p.errorStream.bufferedReader().readText().trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/nav_selected" android:state_checked="true" />
|
||||
<item android:color="@color/nav_unselected" />
|
||||
</selector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#08000000" />
|
||||
<corners android:radius="14dp" />
|
||||
<stroke android:width="0.5dp" android:color="#0D000000" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0F22C55E" />
|
||||
<corners android:radius="14dp" />
|
||||
<stroke android:width="0.5dp" android:color="#2622C55E" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0D3B82F6" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="0.5dp" android:color="#1A3B82F6" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0D22C55E" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="0.5dp" android:color="#1A22C55E" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0DF59E0B" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="0.5dp" android:color="#1AF59E0B" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0D8B5CF6" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="0.5dp" android:color="#1A8B5CF6" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#B3FFFFFF" />
|
||||
<corners android:radius="20dp" />
|
||||
<stroke android:width="0.5dp" android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#D9FFFFFF" />
|
||||
<corners android:radius="20dp" />
|
||||
<stroke android:width="0.5dp" android:color="#40FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#3B82F6"
|
||||
android:endColor="#6366F1"
|
||||
android:angle="135" />
|
||||
<corners android:bottomLeftRadius="28dp" android:bottomRightRadius="28dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="200dp"
|
||||
android:startColor="#403B82F6"
|
||||
android:endColor="#00FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="160dp"
|
||||
android:startColor="#2660A5FA"
|
||||
android:endColor="#00FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1AF59E0B" />
|
||||
<corners android:radius="20dp" />
|
||||
<stroke android:width="0.5dp" android:color="#30F59E0B" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1AEF4444" />
|
||||
<corners android:radius="20dp" />
|
||||
<stroke android:width="0.5dp" android:color="#30EF4444" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1A22C55E" />
|
||||
<corners android:radius="20dp" />
|
||||
<stroke android:width="0.5dp" android:color="#3022C55E" />
|
||||
</shape>
|
||||
10
sdk/android-app/app/src/main/res/drawable/bg_scan_btn.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/bg_scan_btn.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#20FFFFFF">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#20FFFFFF" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
10
sdk/android-app/app/src/main/res/drawable/btn_action.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/btn_action.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/ripple">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card_elevated" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
10
sdk/android-app/app/src/main/res/drawable/btn_brand.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/btn_brand.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#40FFFFFF">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/brand_primary" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#203B82F6">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#14FFFFFF" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="1dp" android:color="#303B82F6" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#40FFFFFF">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#3B82F6"
|
||||
android:endColor="#6366F1"
|
||||
android:angle="135" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#40FFFFFF">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#3B82F6"
|
||||
android:endColor="#6366F1"
|
||||
android:angle="135" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#203B82F6">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#0A3B82F6" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
11
sdk/android-app/app/src/main/res/drawable/btn_outline.xml
Normal file
11
sdk/android-app/app/src/main/res/drawable/btn_outline.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/ripple">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<stroke android:width="1.5dp" android:color="@color/brand_primary" />
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
10
sdk/android-app/app/src/main/res/drawable/btn_primary.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/btn_primary.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/ripple">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/accent_blue_dark" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F0F0F0" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke android:width="1dp" android:color="#DDDDDD" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#3358A6FF">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="@color/accent_blue_dark" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
6
sdk/android-app/app/src/main/res/drawable/card_bg.xml
Normal file
6
sdk/android-app/app/src/main/res/drawable/card_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card_elevated" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
10
sdk/android-app/app/src/main/res/drawable/chat_bubble_ai.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/chat_bubble_ai.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card" />
|
||||
<corners
|
||||
android:topLeftRadius="4dp"
|
||||
android:topRightRadius="16dp"
|
||||
android:bottomLeftRadius="16dp"
|
||||
android:bottomRightRadius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/accent_blue_dark" />
|
||||
<corners
|
||||
android:topLeftRadius="16dp"
|
||||
android:topRightRadius="4dp"
|
||||
android:bottomLeftRadius="16dp"
|
||||
android:bottomRightRadius="16dp" />
|
||||
</shape>
|
||||
11
sdk/android-app/app/src/main/res/drawable/chip_bg.xml
Normal file
11
sdk/android-app/app/src/main/res/drawable/chip_bg.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/ripple">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<stroke android:width="1dp" android:color="@color/bg_card_elevated" />
|
||||
<solid android:color="@color/bg_surface" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 工作手机:云端连接 + 设备网络 + 安全盾牌;保留 adaptive icon 安全区。 -->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/workphone_launcher_art"
|
||||
android:inset="12dp" />
|
||||
10
sdk/android-app/app/src/main/res/drawable/ic_nav_ai.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/ic_nav_ai.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M19,9l1.25,-2.75L23,5l-2.75,-1.25L19,1l-1.25,2.75L15,5l2.75,1.25zM19,15l-1.25,2.75L15,19l2.75,1.25L19,23l1.25,-2.75L23,19l-2.75,-1.25zM11.5,9.5L9,4 6.5,9.5 1,12l5.5,2.5L9,20l2.5,-5.5L17,12l-5.5,-2.5z" />
|
||||
</vector>
|
||||
10
sdk/android-app/app/src/main/res/drawable/ic_nav_control.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/ic_nav_control.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M15,7.5V2H9v5.5l3,3 3,-3zM7.5,9H2v6h5.5l3,-3 -3,-3zM9,16.5V22h6v-5.5l-3,-3 -3,3zM16.5,9l-3,3 3,3H22V9h-5.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M3,3h8v8H3V3zM13,3h8v8h-8V3zM3,13h8v8H3v-8zM13,13h8v8h-8v-8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
||||
</vector>
|
||||
10
sdk/android-app/app/src/main/res/drawable/ic_nav_log.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/ic_nav_log.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM10,17l-3.5,-3.5 1.41,-1.41L10,14.17l4.59,-4.58L16,11l-6,6z"/>
|
||||
</vector>
|
||||
10
sdk/android-app/app/src/main/res/drawable/ic_nav_wechat.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/ic_nav_wechat.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M9.5,4C5.36,4 2,6.69 2,10c0,1.89 1.08,3.56 2.78,4.66L4,17l2.5,-1.18C7.61,16.27 8.53,16.5 9.5,16.5c0.17,0 0.33,-0.01 0.5,-0.02C9.67,15.99 9.5,15.51 9.5,15c0,-3.04 2.69,-5.5 6,-5.5c0.34,0 0.67,0.03 1,0.07C16.12,6.27 13.12,4 9.5,4zM7,9C6.45,9 6,8.55 6,8s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM12,9c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM15.5,10.5c-2.76,0 -5,1.94 -5,4.25s2.24,4.25 5,4.25c0.71,0 1.38,-0.13 2,-0.35L19.5,20l-0.6,-1.87C20.19,17.16 21,15.82 21,14.75 21,12.44 18.26,10.5 15.5,10.5zM14,14c-0.41,0 -0.75,-0.34 -0.75,-0.75s0.34,-0.75 0.75,-0.75 0.75,0.34 0.75,0.75 -0.34,0.75 -0.75,0.75zM17,14c-0.41,0 -0.75,-0.34 -0.75,-0.75s0.34,-0.75 0.75,-0.75 0.75,0.34 0.75,0.75 -0.34,0.75 -0.75,0.75z" />
|
||||
</vector>
|
||||
10
sdk/android-app/app/src/main/res/drawable/ic_scan_qr.xml
Normal file
10
sdk/android-app/app/src/main/res/drawable/ic_scan_qr.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M3,11h2V3h6V1H3v10zM3,23h8v-2H5v-8H3v10zM21,1h-8v2h6v8h2V1zM19,21h-6v2h8V13h-2v8zM7,7h4v4H7zM13,7h4v4h-4zM7,13h4v4H7z" />
|
||||
</vector>
|
||||
7
sdk/android-app/app/src/main/res/drawable/input_bg.xml
Normal file
7
sdk/android-app/app/src/main/res/drawable/input_bg.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card" />
|
||||
<corners android:radius="24dp" />
|
||||
<stroke android:width="1dp" android:color="@color/bg_card_elevated" />
|
||||
</shape>
|
||||
6
sdk/android-app/app/src/main/res/drawable/status_dot.xml
Normal file
6
sdk/android-app/app/src/main/res/drawable/status_dot.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#F44336" />
|
||||
<size android:width="10dp" android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#007AFF" />
|
||||
<size android:width="120dp" android:height="120dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#30007AFF" />
|
||||
<size android:width="160dp" android:height="160dp" />
|
||||
</shape>
|
||||
26
sdk/android-app/app/src/main/res/layout/activity_main.xml
Normal file
26
sdk/android-app/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/bg_primary">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/fragmentContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottomNav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/nav_bg"
|
||||
android:elevation="8dp"
|
||||
app:itemIconTint="@color/bottom_nav_color"
|
||||
app:itemTextColor="@color/bottom_nav_color"
|
||||
app:labelVisibilityMode="labeled"
|
||||
app:menu="@menu/bottom_nav" />
|
||||
|
||||
</LinearLayout>
|
||||
337
sdk/android-app/app/src/main/res/layout/activity_setup.xml
Normal file
337
sdk/android-app/app/src/main/res/layout/activity_setup.xml
Normal file
@@ -0,0 +1,337 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_primary">
|
||||
|
||||
<!-- Decorative orbs -->
|
||||
<View
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="300dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="-60dp"
|
||||
android:layout_marginEnd="-60dp"
|
||||
android:background="@drawable/bg_orb_primary" />
|
||||
|
||||
<View
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="240dp"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginBottom="-40dp"
|
||||
android:layout_marginStart="-40dp"
|
||||
android:background="@drawable/bg_orb_secondary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingTop="60dp"
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<!-- Logo + 标题 -->
|
||||
<FrameLayout
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/btn_gradient_primary" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:src="@drawable/ic_launcher_foreground"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="@string/app_name" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<!-- 进度指示 -->
|
||||
<TextView
|
||||
android:id="@+id/tvStepIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="1/3 · 联网检查"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- 步骤容器 -->
|
||||
<ViewFlipper
|
||||
android:id="@+id/viewFlipper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<!-- 步骤 1:联网检查 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="检查网络连接"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNetworkStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="正在检查网络..."
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStep1Next"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="48dp"
|
||||
android:text="下一步"
|
||||
android:textSize="15sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:textAllCaps="false"
|
||||
android:enabled="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 步骤 2:MIUI 保活权限 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="开启保活权限"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="点击每项进入系统设置开启(建议全部开启以保障稳定运行)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSetupAutoStart"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="42dp"
|
||||
android:text="① 自启动权限"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSetupBattery"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="42dp"
|
||||
android:text="② 省电策略(无限制)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSetupNotify"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="42dp"
|
||||
android:text="③ 通知权限"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSetupA11y"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="42dp"
|
||||
android:text="④ 无障碍服务"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStep2Next"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="48dp"
|
||||
android:text="下一步"
|
||||
android:textSize="15sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 步骤 3:扫码绑定(复用原 ID) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="扫码绑定服务器"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressSetup"
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:visibility="gone"
|
||||
android:indeterminateTint="@color/brand_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSetupStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="对准服务器二维码扫描绑定"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="40dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScan"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="52dp"
|
||||
android:text="扫码绑定"
|
||||
android:textSize="16sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:textAllCaps="false"
|
||||
android:letterSpacing="0.04"
|
||||
android:elevation="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnManualInput"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="48dp"
|
||||
android:text="搜索局域网服务器"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:layout_marginTop="14dp"
|
||||
android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 成功页 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="✓"
|
||||
android:textSize="48sp"
|
||||
android:textColor="@color/accent_green"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="绑定成功"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设备 ID · MD5"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSuccessDeviceId"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="—"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCopySuccess"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="42dp"
|
||||
android:text="复制设备 MD5"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnEnterMain"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="52dp"
|
||||
android:text="进入主页"
|
||||
android:textSize="16sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:textAllCaps="false"
|
||||
android:elevation="4dp" />
|
||||
</LinearLayout>
|
||||
</ViewFlipper>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
375
sdk/android-app/app/src/main/res/layout/fragment_dashboard.xml
Normal file
375
sdk/android-app/app/src/main/res/layout/fragment_dashboard.xml
Normal file
@@ -0,0 +1,375 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_primary"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<!-- Background orb -->
|
||||
<View
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="-40dp"
|
||||
android:layout_marginEnd="-60dp"
|
||||
android:background="@drawable/bg_orb_secondary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Gradient Header -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_header_gradient">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<!-- Title row + scan -->
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="26sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:letterSpacing="0.02" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDeviceIdHeader"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设备ID"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#80FFFFFF"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDeviceIdMd5"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="MD5"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#70FFFFFF"
|
||||
android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnScanQr"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:src="@drawable/ic_scan_qr"
|
||||
android:background="@drawable/bg_scan_btn"
|
||||
android:scaleType="centerInside"
|
||||
android:padding="10dp"
|
||||
android:contentDescription="扫码绑定" />
|
||||
</RelativeLayout>
|
||||
|
||||
<!-- Status pill -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOnlineStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="离线"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/accent_red"
|
||||
android:background="@drawable/bg_pill_offline"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:paddingVertical="5dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerAddr"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="未绑定"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#B0FFFFFF"
|
||||
android:layout_marginStart="12dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="middle" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Content below header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:layout_marginTop="-16dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<!-- Device Info Card (glass, overlapping header) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProjectId"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="项目 —"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvConnectStage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="连接阶段 —"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHardwareInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="加载中..."
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:lineSpacingExtra="5dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Section: Capabilities -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="16dp"
|
||||
android:background="@color/brand_primary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="可用功能"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<GridLayout
|
||||
android:id="@+id/gridCapabilities"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:columnCount="3"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:useDefaultMargins="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCapRoot"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_columnWeight="1"
|
||||
android:text="Root"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:background="@drawable/bg_capability_off"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center"
|
||||
android:layout_margin="3dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCapHook"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_columnWeight="1"
|
||||
android:text="Hook"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:background="@drawable/bg_capability_off"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center"
|
||||
android:layout_margin="3dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCapA11y"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_columnWeight="1"
|
||||
android:text="无障碍"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:background="@drawable/bg_capability_off"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center"
|
||||
android:layout_margin="3dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCapAntiBan"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_columnWeight="1"
|
||||
android:text="防封"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:background="@drawable/bg_capability_off"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center"
|
||||
android:layout_margin="3dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCapWechat"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_columnWeight="1"
|
||||
android:text="微信"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:background="@drawable/bg_capability_off"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center"
|
||||
android:layout_margin="3dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCapAI"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_columnWeight="1"
|
||||
android:text="AI"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:background="@drawable/bg_capability_off"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center"
|
||||
android:layout_margin="3dp" />
|
||||
</GridLayout>
|
||||
|
||||
<!-- §〇 四端互通状态卡:SDK+WS 双绿时存客宝/触客宝/AI 可调 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="16dp"
|
||||
android:elevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="16dp"
|
||||
android:background="@color/brand_primary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="四端互通"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvInteropStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="离线"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/accent_red"
|
||||
android:background="@drawable/bg_pill_offline"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvInteropDetail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="等待 SDK + WS 双绿"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnReconnect"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_weight="1"
|
||||
android:text="重新连接"
|
||||
android:textSize="15sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:textAllCaps="false"
|
||||
android:elevation="2dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRestartService"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_weight="1"
|
||||
android:text="重启服务"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:layout_marginStart="6dp"
|
||||
android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</ScrollView>
|
||||
62
sdk/android-app/app/src/main/res/layout/fragment_log.xml
Normal file
62
sdk/android-app/app/src/main/res/layout/fragment_log.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_primary"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="运行日志"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnClearLog"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="清空"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/accent_red"
|
||||
android:background="@drawable/btn_outline"
|
||||
android:paddingHorizontal="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/divider" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/logScrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLogContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp"
|
||||
android:text="等待日志..."
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="@color/text_primary"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:gravity="top" />
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
477
sdk/android-app/app/src/main/res/layout/fragment_settings_v2.xml
Normal file
477
sdk/android-app/app/src/main/res/layout/fragment_settings_v2.xml
Normal file
@@ -0,0 +1,477 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_primary"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<View
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="-30dp"
|
||||
android:layout_marginEnd="-50dp"
|
||||
android:background="@drawable/bg_orb_secondary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<!-- Title -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="24dp"
|
||||
android:background="@color/accent_purple" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设备"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:letterSpacing="0.02"
|
||||
android:layout_marginStart="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Bind Info -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="绑定信息"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="服务器 未配置"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProjectId"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="项目 —"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDeviceId"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="设备ID —"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCopyDeviceId"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:text="复制"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 业务绑定指引 · CKB-147 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="业务绑定指引"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="本机由工作手机主动扫描系统二维码完成服务器与项目绑定:"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1. 在系统设备管理中生成绑定二维码"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="2. 回到工作台点击右上角扫码按钮"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="3. 工作手机扫描二维码后自动连接并上报设备"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnShowDeviceQr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="旧版设备二维码(已停用)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- MIUI 保活 checklist · WP-AGENT-01 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="保活权限清单"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnKeepAutoStart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="① 自启动权限(允许)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnKeepBattery"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="② 省电策略(无限制)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnKeepNotify"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="③ 通知权限(允许)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnKeepA11y"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="④ 无障碍服务(开启)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- WP-PMAX-01 Root 隐藏 checklist · 量产黄灯必备 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Root 隐藏清单"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="量产黄灯必备:防微信检测 Root/Frida"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRootDenyList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="① Magisk DenyList(勾选微信)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRootShamiko"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="② Shamiko 模块(确认已启用)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRootCheckPort"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="③ 检测 27042 端口(Frida 默认)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingStart="16dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Service Control -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="服务控制"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="14dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRebind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="重新绑定服务器"
|
||||
android:textSize="15sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:textAllCaps="false"
|
||||
android:elevation="2dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnOpenA11y"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="开启无障碍服务"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:textAllCaps="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRestartService"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="重启服务"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/accent_green"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:textAllCaps="false" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStopService"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="停止服务"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/accent_red"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:layout_marginStart="5dp"
|
||||
android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Log Viewer -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="运行日志"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnToggleLog"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:text="展开"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginEnd="6dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnClearLog"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:text="清空"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/accent_red"
|
||||
android:background="@drawable/btn_ios_secondary"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
</RelativeLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/logScrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLogContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="点击「展开」查看日志"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_version_tagline"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</ScrollView>
|
||||
164
sdk/android-app/app/src/main/res/layout/fragment_status.xml
Normal file
164
sdk/android-app/app/src/main/res/layout/fragment_status.xml
Normal file
@@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_primary"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="运行状态"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- 连接状态卡片 -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="@color/bg_card">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="连接通道"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvConnection"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="检测中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRoot"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Root: 检测中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFrida"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Frida: 检测中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvA11y"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="无障碍: 检测中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- AI引擎卡片 -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="@color/bg_card">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="智能引擎"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAiBrain"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="AI Brain: 检测中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAntiBan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="防封模块: 检测中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- 设备信息卡片 -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="@color/bg_card">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设备信息"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDevice"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="加载中..."
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:lineSpacingExtra="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
415
sdk/android-app/app/src/main/res/layout/fragment_wechat.xml
Normal file
415
sdk/android-app/app/src/main/res/layout/fragment_wechat.xml
Normal file
@@ -0,0 +1,415 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_primary"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<View
|
||||
android:layout_width="220dp"
|
||||
android:layout_height="220dp"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginTop="-40dp"
|
||||
android:layout_marginStart="-50dp"
|
||||
android:background="@drawable/bg_orb_secondary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<!-- Title -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="24dp"
|
||||
android:background="@color/accent_green" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="微信 · 防封"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:letterSpacing="0.02"
|
||||
android:layout_marginStart="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- AB-02/03 风险 Banner:level>=3 红条熔断 / level==2 黄条降速 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bannerCircuitBreaker"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/bg_pill_offline"
|
||||
android:padding="14dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBannerTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="已熔断,自动化已暂停"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent_red" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCooldownRemaining"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/accent_red" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Status Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="18dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:text="微信状态"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvWxRunning"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:text="未运行"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/accent_red"
|
||||
android:background="@drawable/bg_pill_offline"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:paddingVertical="4dp" />
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvWxVersion"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="版本 —"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHookStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hook 未注入"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Capability data cards -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardMsg"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_data_card_blue"
|
||||
android:padding="14dp"
|
||||
android:layout_marginEnd="5dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="消息"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@color/text_secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMsgCapability"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="—"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent_blue"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_data_card_green"
|
||||
android:padding="14dp"
|
||||
android:layout_marginHorizontal="5dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="通讯录"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@color/text_secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvContactCapability"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="—"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent_green"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_data_card_purple"
|
||||
android:padding="14dp"
|
||||
android:layout_marginStart="5dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="朋友圈"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@color/text_secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMomentCapability"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="—"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent_purple"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="16dp"
|
||||
android:background="@color/accent_blue" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="快捷操作"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnOpenWechat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:text="打开微信"
|
||||
android:textSize="15sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:textAllCaps="false"
|
||||
android:elevation="2dp"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnGetContacts"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="获取通讯录"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginEnd="5dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnGetMessages"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="获取消息"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginStart="5dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvWechatResult"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="执行结果 等待操作"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="14dp"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<!-- Safety card · runtime -->
|
||||
<LinearLayout
|
||||
android:id="@+id/cardAntiBan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_glass_card_solid"
|
||||
android:padding="16dp"
|
||||
android:elevation="1dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="防封运行时"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAntiBanStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="防封引擎 初始化中"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRiskLevel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="风险等级 —"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/accent_green"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvQuota"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="今日配额 —/min · —/hr"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNurturePhase"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="暖机阶段 —"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 智能助手入口 · WP-WX-03 解封 / WP-WX-04 扫码加友 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="14dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="16dp"
|
||||
android:background="@color/accent_purple" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="智能助手"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnUnblockAssistant"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="解封助手(联系客服)"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:background="@drawable/btn_gradient_primary"
|
||||
android:textAllCaps="false"
|
||||
android:elevation="2dp"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScanAddFriend"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="二维码图片加好友"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginBottom="10dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</ScrollView>
|
||||
15
sdk/android-app/app/src/main/res/menu/bottom_nav.xml
Normal file
15
sdk/android-app/app/src/main/res/menu/bottom_nav.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_dashboard"
|
||||
android:icon="@drawable/ic_nav_home"
|
||||
android:title="连接" />
|
||||
<item
|
||||
android:id="@+id/nav_wechat"
|
||||
android:icon="@drawable/ic_nav_wechat"
|
||||
android:title="微信·防封" />
|
||||
<item
|
||||
android:id="@+id/nav_settings"
|
||||
android:icon="@drawable/ic_nav_settings"
|
||||
android:title="设备" />
|
||||
</menu>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
sdk/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
sdk/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user