feat: publish workphone SDK deployment and API docs

This commit is contained in:
Manus AI
2026-07-14 18:10:52 +08:00
commit 021d633cc1
534 changed files with 122391 additions and 0 deletions

View 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'
}

View File

@@ -0,0 +1,4 @@
# Add project specific ProGuard rules here.
# Keep WebSocket classes
-keep class org.java_websocket.** { *; }
-keep class com.google.gson.** { *; }

View 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 为 LauncherMainActivity 仍由服务/通知拉起 -->
<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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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})")
}
}

View File

@@ -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,
)
}

View File

@@ -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
}
}
}
}

View File

@@ -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-06level<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()
}
}

View File

@@ -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"
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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),
)
}
}

View File

@@ -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))
}
}

View File

@@ -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-05UDP 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 基址。
*
* 与 LanDiscoveryTCP 子网扫描互补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()
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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?>
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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 读版本。
*
* 说明:无法做进程内 HookJS 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",
)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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 -> "同步服务运行中"
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 wxidroot 读 SharedPreferences login_weixin_usernameFrida 兜底)
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)
}
}

View File

@@ -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")))
}
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
"{}"
}
}
}
}

View File

@@ -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 保活 checklistWP-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()
}
}

View File

@@ -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保活 checklistWP-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
}
}

View File

@@ -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 "未运行"}")
}
}
}
}
}

View File

@@ -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("风险等级 $levelNameL$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 不支持时返回 503UI 诚实提示 */
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 skillFrida 未就绪返回 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}"
}
}

View File

@@ -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) }
}
}

View File

@@ -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() }
}
}

View File

@@ -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 }
}
}

View File

@@ -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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#08000000" />
<corners android:radius="14dp" />
<stroke android:width="0.5dp" android:color="#0D000000" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0F22C55E" />
<corners android:radius="14dp" />
<stroke android:width="0.5dp" android:color="#2622C55E" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0D3B82F6" />
<corners android:radius="16dp" />
<stroke android:width="0.5dp" android:color="#1A3B82F6" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0D22C55E" />
<corners android:radius="16dp" />
<stroke android:width="0.5dp" android:color="#1A22C55E" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0DF59E0B" />
<corners android:radius="16dp" />
<stroke android:width="0.5dp" android:color="#1AF59E0B" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0D8B5CF6" />
<corners android:radius="16dp" />
<stroke android:width="0.5dp" android:color="#1A8B5CF6" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#B3FFFFFF" />
<corners android:radius="20dp" />
<stroke android:width="0.5dp" android:color="#30FFFFFF" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#D9FFFFFF" />
<corners android:radius="20dp" />
<stroke android:width="0.5dp" android:color="#40FFFFFF" />
</shape>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1AF59E0B" />
<corners android:radius="20dp" />
<stroke android:width="0.5dp" android:color="#30F59E0B" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1AEF4444" />
<corners android:radius="20dp" />
<stroke android:width="0.5dp" android:color="#30EF4444" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1A22C55E" />
<corners android:radius="20dp" />
<stroke android:width="0.5dp" android:color="#3022C55E" />
</shape>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F0F0F0" />
<corners android:radius="8dp" />
<stroke android:width="1dp" android:color="#DDDDDD" />
</shape>

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card_elevated" />
<corners android:radius="12dp" />
</shape>

View 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>

View 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/accent_blue_dark" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="4dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>

View 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>

View File

@@ -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" />

View 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>

View 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>

View 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,3h8v8H3V3zM13,3h8v8h-8V3zM3,13h8v8H3v-8zM13,13h8v8h-8v-8z" />
</vector>

View File

@@ -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>

View 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>

View 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.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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="@color/bg_card_elevated" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#F44336" />
<size android:width="10dp" android:height="10dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#007AFF" />
<size android:width="120dp" android:height="120dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#30007AFF" />
<size android:width="160dp" android:height="160dp" />
</shape>

View 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>

View 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>
<!-- 步骤 2MIUI 保活权限 -->
<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>

View 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>

View 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>

View 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>

View 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>

View 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 风险 Bannerlevel>=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>

View 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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Some files were not shown because too many files have changed in this diff Show More