From 10172a822f140bd4da76500aae2efcb7d9421921 Mon Sep 17 00:00:00 2001 From: DrDavidDa <128883761+DrDavidDa@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:28:41 +0800 Subject: [PATCH] feat: harden device-owner setup and agent control --- .gitignore | 3 + README.md | 2 + app/build.gradle | 10 + app/src/debug/AndroidManifest.xml | 27 ++ app/src/main/AndroidManifest.xml | 12 +- .../andforce/andclaw/ActionTestActivity.kt | 2 +- .../andclaw/AgentAccessibilityService.kt | 95 ++++++- .../com/andforce/andclaw/AgentController.kt | 210 ++++++++++---- .../andclaw/AgentConversationContext.kt | 38 +++ .../andforce/andclaw/AgentDebugReceiver.kt | 38 +++ app/src/main/java/com/andforce/andclaw/App.kt | 5 + .../andforce/andclaw/ChatHistoryActivity.kt | 63 +++++ .../main/java/com/andforce/andclaw/Utils.kt | 106 +++++++- .../main/res/layout/activity_chat_history.xml | 45 +++ .../andclaw/AgentConversationContextTest.kt | 47 ++++ .../com/andforce/andclaw/UtilsPromptTest.kt | 35 +++ docs/device-owner/README.md | 99 +++++++ docs/device-owner/xiaomi-miui.md | 59 ++++ gradle/gradle-daemon-jvm.properties | 1 - mdm/build.gradle | 2 - .../policy/locktask/AndclawAgentReadiness.kt | 256 ++++++++++++++++++ .../locktask/AndclawDeviceOwnerBootstrap.kt | 82 ++++++ .../policy/locktask/SetupKioskModeActivity.kt | 250 ++++++++--------- .../layout/activity_setup_kiosk_layout.xml | 62 +++++ .../locktask/AndclawAgentReadinessTest.kt | 80 ++++++ tools/device-owner/00-diagnose-reset-state.sh | 25 ++ .../device-owner/10-provision-single-owner.sh | 34 +++ .../20-repair-single-owner-runtime.sh | 31 +++ tools/device-owner/30-verify-single-owner.sh | 36 +++ tools/device-owner/35-reboot-and-reverify.sh | 14 + .../device-owner/40-bootstrap-after-reset.sh | 15 + .../device-owner/50-capture-agent-baseline.sh | 77 ++++++ tools/device-owner/common.sh | 73 +++++ 33 files changed, 1742 insertions(+), 192 deletions(-) create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/main/java/com/andforce/andclaw/AgentConversationContext.kt create mode 100644 app/src/main/java/com/andforce/andclaw/AgentDebugReceiver.kt create mode 100644 app/src/test/java/com/andforce/andclaw/AgentConversationContextTest.kt create mode 100644 app/src/test/java/com/andforce/andclaw/UtilsPromptTest.kt create mode 100644 docs/device-owner/README.md create mode 100644 docs/device-owner/xiaomi-miui.md create mode 100644 mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawAgentReadiness.kt create mode 100644 mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawDeviceOwnerBootstrap.kt create mode 100644 mdm/src/test/java/com/afwsamples/testdpc/policy/locktask/AndclawAgentReadinessTest.kt create mode 100755 tools/device-owner/00-diagnose-reset-state.sh create mode 100755 tools/device-owner/10-provision-single-owner.sh create mode 100755 tools/device-owner/20-repair-single-owner-runtime.sh create mode 100755 tools/device-owner/30-verify-single-owner.sh create mode 100755 tools/device-owner/35-reboot-and-reverify.sh create mode 100755 tools/device-owner/40-bootstrap-after-reset.sh create mode 100755 tools/device-owner/50-capture-agent-baseline.sh create mode 100755 tools/device-owner/common.sh diff --git a/.gitignore b/.gitignore index e2a5614..8ca1b19 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ google-services.json # Android Profiling *.hprof + +# Local operational artifacts +out/ diff --git a/README.md b/README.md index 7f58011..b594746 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ - ✅ **Kiosk 模式**:单应用锁定(Lock Task)、替换默认桌面、禁止安全模式/恢复出厂 > 详细能力清单见 [ACTIONS.md](./ACTIONS.md) + > + > 实机恢复出厂后的完整落地流程、验收脚本和小米 / MIUI 踩坑记录见 [docs/device-owner/README.md](./docs/device-owner/README.md)。 6. **创建 Telegram 机器人** diff --git a/app/build.gradle b/app/build.gradle index 8e87736..2c0da1b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,6 +29,16 @@ android { } buildTypes { + debug { + applicationIdSuffix ".local" + versionNameSuffix "-local" + } + ownerDebug { + initWith(debug) + applicationIdSuffix "" + versionNameSuffix "-owner" + matchingFallbacks = ['debug'] + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..822ec68 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 338331c..c4fa551 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/andforce/andclaw/ActionTestActivity.kt b/app/src/main/java/com/andforce/andclaw/ActionTestActivity.kt index b09e2d1..643a92f 100644 --- a/app/src/main/java/com/andforce/andclaw/ActionTestActivity.kt +++ b/app/src/main/java/com/andforce/andclaw/ActionTestActivity.kt @@ -39,7 +39,7 @@ class ActionTestActivity : AppCompatActivity() { companion object { private const val TAG = "ActionTest" private const val TEST_DOWNLOAD_URL = - "https://raw.githubusercontent.com/nicehash/NiceHashQuickMiner/main/LICENSE" + "https://www.baidu.com/robots.txt" } private lateinit var binding: ActivityActionTestBinding diff --git a/app/src/main/java/com/andforce/andclaw/AgentAccessibilityService.kt b/app/src/main/java/com/andforce/andclaw/AgentAccessibilityService.kt index d4d69f8..978c422 100644 --- a/app/src/main/java/com/andforce/andclaw/AgentAccessibilityService.kt +++ b/app/src/main/java/com/andforce/andclaw/AgentAccessibilityService.kt @@ -13,6 +13,7 @@ import android.util.Log import android.view.Display import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo import java.util.concurrent.Executor @SuppressLint("AccessibilityPolicy") @@ -25,9 +26,20 @@ class AgentAccessibilityService : AccessibilityService() { override fun onServiceConnected() { instance = this } fun captureScreenHierarchy(): String { - val root = rootInActiveWindow ?: return "Empty Screen" val sb = StringBuilder() - parseNode(root, sb) + val roots = getWindowRoots() + if (roots.isEmpty()) return "Empty Screen" + roots.forEachIndexed { index, root -> + val window = root.window + sb.append( + "[window:$index" + + ",type=${window?.type}" + + ",focused=${window?.isFocused}" + + ",active=${window?.isActive}" + + ",pkg=${root.packageName}]\n" + ) + parseNode(root, sb) + } return sb.toString() } @@ -41,28 +53,28 @@ class AgentAccessibilityService : AccessibilityService() { for (i in 0 until node.childCount) parseNode(node.getChild(i), sb) } - fun click(x: Int, y: Int) { + fun click(x: Int, y: Int): Boolean { val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 50)).build() - dispatchGesture(gesture, null, null) + return dispatchGesture(gesture, null, null) } - fun swipe(startX: Int, startY: Int, endX: Int, endY: Int, durationMs: Long = 300) { + fun swipe(startX: Int, startY: Int, endX: Int, endY: Int, durationMs: Long = 300): Boolean { val path = Path().apply { moveTo(startX.toFloat(), startY.toFloat()) lineTo(endX.toFloat(), endY.toFloat()) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, durationMs)).build() - dispatchGesture(gesture, null, null) + return dispatchGesture(gesture, null, null) } - fun longPress(x: Int, y: Int, durationMs: Long = 1000) { + fun longPress(x: Int, y: Int, durationMs: Long = 1000): Boolean { val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, durationMs)).build() - dispatchGesture(gesture, null, null) + return dispatchGesture(gesture, null, null) } private val browserPackages = setOf( @@ -137,6 +149,73 @@ class AgentAccessibilityService : AccessibilityService() { fun globalAction(action: Int): Boolean = performGlobalAction(action) + fun clickNodeByText(vararg texts: String): Boolean { + val targets = texts + .map { it.trim() } + .filter { it.isNotEmpty() } + if (targets.isEmpty()) return false + + for (root in getWindowRoots()) { + val node = findNodeByText(root, targets) ?: continue + if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { + return true + } + + val rect = Rect() + node.getBoundsInScreen(rect) + if (!rect.isEmpty) { + return click(rect.centerX(), rect.centerY()) + } + } + return false + } + + private fun getWindowRoots(): List { + val interactiveRoots = windows + ?.mapNotNull(AccessibilityWindowInfo::getRoot) + ?.filterNotNull() + ?: emptyList() + if (interactiveRoots.isNotEmpty()) return interactiveRoots + + val activeRoot = rootInActiveWindow + return if (activeRoot != null) listOf(activeRoot) else emptyList() + } + + private fun findNodeByText( + node: AccessibilityNodeInfo, + targets: List, + depth: Int = 0 + ): AccessibilityNodeInfo? { + if (depth > 25) return null + + val text = node.text?.toString()?.trim() + val contentDesc = node.contentDescription?.toString()?.trim() + val matches = targets.any { target -> + text.equals(target, ignoreCase = true) || contentDesc.equals(target, ignoreCase = true) + } + if (matches) { + return findClickableAncestor(node) ?: node + } + + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + val result = findNodeByText(child, targets, depth + 1) + if (result != null) return result + } + return null + } + + private fun findClickableAncestor(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + var current = node + var hops = 0 + while (current != null && hops < 8) { + if (current.isClickable) return current + current = current.parent + hops++ + } + return null + } + fun captureScreenshot(callback: (Bitmap?) -> Unit) { takeScreenshot( Display.DEFAULT_DISPLAY, diff --git a/app/src/main/java/com/andforce/andclaw/AgentController.kt b/app/src/main/java/com/andforce/andclaw/AgentController.kt index b4f6286..6790d35 100644 --- a/app/src/main/java/com/andforce/andclaw/AgentController.kt +++ b/app/src/main/java/com/andforce/andclaw/AgentController.kt @@ -7,17 +7,18 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.util.DisplayMetrics import android.media.AudioManager import android.net.Uri import android.provider.MediaStore import android.util.Base64 import android.util.Log +import android.view.WindowManager import com.andforce.andclaw.model.AgentUiState import com.andforce.andclaw.model.AiAction import com.andforce.andclaw.model.ApiConfig import com.andforce.andclaw.model.ChatMessage import com.afwsamples.testdpc.common.Util -import com.google.gson.Gson import com.base.services.IAiConfigService import com.base.services.ITgBridgeService import kotlinx.coroutines.* @@ -35,11 +36,15 @@ object AgentController : ITgBridgeService, IAiConfigService { private const val TAG = "AgentController" private const val PREFS_NAME = "agent_config" + private const val KEY_PROVIDER = "provider" + private const val KEY_API_URL = "api_url" + private const val KEY_API_KEY = "api_key" + private const val KEY_MODEL = "model" + private const val KEY_TG_ALLOWED_CHAT_ID = "tg_allowed_chat_id" + private const val KEY_TG_TOKEN = "tg_token" private lateinit var appContext: Context private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private val gson = Gson() - private var tgJob: Job? = null private var tgBotClient: TgBotClient? = null var tgActiveChatId: Long = 0L @@ -47,21 +52,35 @@ object AgentController : ITgBridgeService, IAiConfigService { private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages + private val _uiState = MutableStateFlow(AgentUiState()) + val uiStateFlow: StateFlow = _uiState var config = ApiConfig(apiKey = BuildConfig.KIMI_KEY) private set var isAgentRunning = false private set private var agentJob: Job? = null + private var currentTaskStartIndex = 0 private var consecutiveSameCount = 0 private var lastFingerprint = "" private var loopRetryCount = 0 - private var uiState = AgentUiState() + private var uiState = _uiState.value + set(value) { + field = value + _uiState.value = value + } private val dpmBridge by lazy { DpmBridge(appContext) } + private data class IntentExecutionResult( + val success: Boolean, + val message: String? = null + ) + fun init(context: Context) { appContext = context.applicationContext + config = loadConfig() + uiState = uiState.copy(aiProvider = config.provider) } override val provider: String get() = config.provider @@ -72,23 +91,48 @@ object AgentController : ITgBridgeService, IAiConfigService { override fun updateConfig(provider: String, apiUrl: String, apiKey: String, model: String) { config = config.copy(provider = provider, apiUrl = apiUrl, apiKey = apiKey, model = model) + persistConfig(config) + uiState = uiState.copy(aiProvider = provider) } - override fun getTgChatId(): Long = getPrefs().getLong("tg_allowed_chat_id", 0L) + override fun getTgChatId(): Long = getPrefs().getLong(KEY_TG_ALLOWED_CHAT_ID, 0L) override fun setTgChatId(chatId: Long) { - getPrefs().edit().putLong("tg_allowed_chat_id", chatId).apply() + getPrefs().edit().putLong(KEY_TG_ALLOWED_CHAT_ID, chatId).apply() } override val tgToken: String - get() = getPrefs().getString("tg_token", null) ?: BuildConfig.TG_TOKEN + get() = getPrefs().getString(KEY_TG_TOKEN, null) ?: BuildConfig.TG_TOKEN override fun setTgToken(token: String) { - getPrefs().edit().putString("tg_token", token).apply() + getPrefs().edit().putString(KEY_TG_TOKEN, token).apply() } fun getPrefs() = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun loadConfig(): ApiConfig { + val prefs = getPrefs() + return config.copy( + provider = prefs.getString(KEY_PROVIDER, config.provider) ?: config.provider, + apiUrl = prefs.getString(KEY_API_URL, config.apiUrl) ?: config.apiUrl, + apiKey = if (prefs.contains(KEY_API_KEY)) { + prefs.getString(KEY_API_KEY, config.apiKey) ?: "" + } else { + config.apiKey + }, + model = prefs.getString(KEY_MODEL, config.model) ?: config.model + ) + } + + private fun persistConfig(config: ApiConfig) { + getPrefs().edit() + .putString(KEY_PROVIDER, config.provider) + .putString(KEY_API_URL, config.apiUrl) + .putString(KEY_API_KEY, config.apiKey) + .putString(KEY_MODEL, config.model) + .apply() + } + // --- ITgBridgeService --- override fun startBridge() { @@ -101,7 +145,7 @@ object AgentController : ITgBridgeService, IAiConfigService { tgJob = scope.launch(Dispatchers.IO) { while (isActive) { try { - val allowedChatId = getPrefs().getLong("tg_allowed_chat_id", 0L) + val allowedChatId = getPrefs().getLong(KEY_TG_ALLOWED_CHAT_ID, 0L) val updates = tgBotClient?.poll() ?: emptyList() for (msg in updates) { if (allowedChatId != 0L && msg.chatId != allowedChatId) continue @@ -123,7 +167,7 @@ object AgentController : ITgBridgeService, IAiConfigService { tgActiveChatId = chatId when (text) { "/status" -> { - val allowedId = getPrefs().getLong("tg_allowed_chat_id", 0L) + val allowedId = getPrefs().getLong(KEY_TG_ALLOWED_CHAT_ID, 0L) val accessInfo = if (allowedId == 0L) "⚠️ 未设置 Chat ID 白名单" else "✅ Chat ID 已锁定" val agentInfo = if (isAgentRunning) "▶️ Agent 运行中: ${uiState.userInput}" else "⏸ Agent 空闲" tgBotClient?.send(chatId, "Andclaw 状态\n$agentInfo\n$accessInfo\n你的 Chat ID: $chatId", msgId) @@ -141,9 +185,16 @@ object AgentController : ITgBridgeService, IAiConfigService { // --- Agent Logic --- fun startAgent(input: String) { + if (input.isBlank() || isAgentRunning) return + + currentTaskStartIndex = _messages.value.size addMessage("user", input) isAgentRunning = true - uiState = uiState.copy(isRunning = true, userInput = input) + uiState = uiState.copy( + isRunning = true, + status = "Agent Running...", + userInput = input + ) consecutiveSameCount = 0 lastFingerprint = "" loopRetryCount = 0 @@ -167,34 +218,12 @@ object AgentController : ITgBridgeService, IAiConfigService { val screenData = svc?.captureScreenHierarchy() ?: "Screen data inaccessible" var finalScreenshot = screenshotBase64 - if (finalScreenshot == null && svc?.isWebViewContext() == true) { + val isKimi = config.provider.equals("Kimi Code", ignoreCase = true) + if (finalScreenshot == null && (svc?.isWebViewContext() == true || isKimi)) { finalScreenshot = captureScreenBase64() } - val currentMessages = _messages.value - val historyContext = currentMessages.takeLast(12).mapNotNull { - when (it.role) { - "user" -> mapOf("role" to "user", "content" to it.content) - "ai" -> it.action?.let { action -> - mapOf("role" to "assistant", "content" to gson.toJson(action)) - } - "system" -> { - val content = it.content - val shouldKeep = content.startsWith("Intent failed:") || - content.startsWith("Loop detected") || - content.startsWith("Execution Exception:") || - content.startsWith("Error occurred:") || - content.startsWith("AI Request Failed:") || - (content.startsWith("Action success.") && content.contains("\n")) - if (shouldKeep) { - mapOf("role" to "user", "content" to "System feedback: $content") - } else { - null - } - } - else -> null - } - } + val historyContext = AgentConversationContext.buildHistory(_messages.value, currentTaskStartIndex) try { val isDeviceOwner = Util.isDeviceOwner(appContext) @@ -268,17 +297,24 @@ object AgentController : ITgBridgeService, IAiConfigService { when (action.type) { AiAction.TYPE_INTENT -> { - addMessage("ai", action.reason ?: "I will use a system shortcut.", action) - executeIntent(action) + val intentResult = executeIntent(action) + if (!intentResult.success) { + addMessage("system", intentResult.message ?: "Intent failed.") + scope.launch { + delay(1500) + executeAgentStep(uiState.userInput) + } + return + } val isTerminal = action.action?.let { it.contains("ALARM") || it.contains("SEND") } ?: false if (isTerminal) { - addMessage("system", "Task dispatched via system.") + addMessage("system", intentResult.message ?: "Task dispatched via system.") stopAgent() } else { - addMessage("system", "App opened, checking next step...") + addMessage("system", intentResult.message ?: "App opened, checking next step...") isAgentRunning = true scope.launch { delay(3000) @@ -345,10 +381,16 @@ object AgentController : ITgBridgeService, IAiConfigService { try { when (action.type) { AiAction.TYPE_CLICK -> { - withContext(Dispatchers.Main) { - AgentAccessibilityService.instance?.click(action.x, action.y) + val svc = AgentAccessibilityService.instance + if (svc == null) { + outputMsg = "Accessibility service not running" + } else { + val result = withContext(Dispatchers.Main) { + svc.click(action.x, action.y) + } + success = result + if (!result) outputMsg = "Failed to dispatch click gesture" } - success = true } AiAction.TYPE_SWIPE -> { @@ -357,10 +399,11 @@ object AgentController : ITgBridgeService, IAiConfigService { outputMsg = "Accessibility service not running" } else { val dur = if (action.duration > 0) action.duration else 300L - withContext(Dispatchers.Main) { + val result = withContext(Dispatchers.Main) { svc.swipe(action.x, action.y, action.endX, action.endY, dur) } - success = true + success = result + if (!result) outputMsg = "Failed to dispatch swipe gesture" } } @@ -370,10 +413,11 @@ object AgentController : ITgBridgeService, IAiConfigService { outputMsg = "Accessibility service not running" } else { val dur = if (action.duration > 0) action.duration else 1000L - withContext(Dispatchers.Main) { + val result = withContext(Dispatchers.Main) { svc.longPress(action.x, action.y, dur) } - success = true + success = result + if (!result) outputMsg = "Failed to dispatch long press gesture" } } @@ -409,8 +453,9 @@ object AgentController : ITgBridgeService, IAiConfigService { } } if (actionId >= 0) { - withContext(Dispatchers.Main) { svc.globalAction(actionId) } - success = true + val result = withContext(Dispatchers.Main) { svc.globalAction(actionId) } + success = result + if (!result) outputMsg = "Failed to perform global action: ${action.globalAction}" } } } @@ -599,9 +644,45 @@ object AgentController : ITgBridgeService, IAiConfigService { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } appContext.startActivity(recordIntent) - delay(1500) - success = true - outputMsg = "录屏授权对话框已弹出,请在下一步点击「立即开始」按钮完成授权" + delay(1200) + + val autoConfirmed = withContext(Dispatchers.Main) { + AgentAccessibilityService.instance?.clickNodeByText( + "立即开始", + "Start now", + "Start" + ) ?: false + } + if (autoConfirmed) { + addMessage("system", "已自动点击录屏授权按钮,等待录屏启动...") + } + + var waited = 0L + while (!ScreenRecordService.isRecording && waited < 8000L) { + if (waited >= 1600L && waited % 1200L == 0L) { + val retried = withContext(Dispatchers.Main) { + val svc = AgentAccessibilityService.instance + when { + svc == null -> false + svc.clickNodeByText("立即开始", "Start now", "Start") -> true + else -> clickScreenRecordPermissionFallback(svc) + } + } + if (retried) { + addMessage("system", "已尝试兜底点击录屏授权确认按钮...") + } + } + delay(400) + waited += 400 + } + + if (ScreenRecordService.isRecording) { + success = true + outputMsg = "录屏已开始" + } else { + success = true + outputMsg = "录屏授权对话框已弹出,请在下一步点击「立即开始」按钮完成授权" + } } } } @@ -693,7 +774,7 @@ object AgentController : ITgBridgeService, IAiConfigService { } } - private fun executeIntent(action: AiAction) { + private fun executeIntent(action: AiAction): IntentExecutionResult { try { Intent(action.action).let { intent -> if (!action.data.isNullOrEmpty()) { @@ -707,10 +788,31 @@ object AgentController : ITgBridgeService, IAiConfigService { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) action.fillIntentExtras(intent) appContext.startActivity(intent) + val message = if (!action.packageName.isNullOrEmpty()) { + "Intent launched: ${action.packageName}" + } else if (!action.data.isNullOrEmpty()) { + "Intent launched: ${action.data}" + } else { + "Intent launched: ${action.action}" + } + return IntentExecutionResult(success = true, message = message) } } catch (e: Exception) { - addMessage("system", "Intent failed: ${e.message}") + return IntentExecutionResult(success = false, message = "Intent failed: ${e.message}") } + return IntentExecutionResult(success = false, message = "Intent failed: empty intent") + } + + private fun clickScreenRecordPermissionFallback(service: AgentAccessibilityService): Boolean { + val wm = appContext.getSystemService(Context.WINDOW_SERVICE) as? WindowManager ?: return false + val metrics = DisplayMetrics() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealMetrics(metrics) + + val confirmX = (metrics.widthPixels * 0.72f).toInt() + val confirmY = (metrics.heightPixels * 0.88f).toInt() + service.click(confirmX, confirmY) + return true } // --- Helpers --- diff --git a/app/src/main/java/com/andforce/andclaw/AgentConversationContext.kt b/app/src/main/java/com/andforce/andclaw/AgentConversationContext.kt new file mode 100644 index 0000000..cd6f206 --- /dev/null +++ b/app/src/main/java/com/andforce/andclaw/AgentConversationContext.kt @@ -0,0 +1,38 @@ +package com.andforce.andclaw + +import com.andforce.andclaw.model.ChatMessage +import com.google.gson.Gson + +internal object AgentConversationContext { + private val gson = Gson() + + fun buildHistory(messages: List, sessionStartIndex: Int): List> { + val safeStartIndex = sessionStartIndex.coerceIn(0, messages.size) + return messages + .drop(safeStartIndex) + .takeLast(12) + .mapNotNull { message -> + when (message.role) { + "user" -> mapOf("role" to "user", "content" to message.content) + "ai" -> message.action?.let { action -> + mapOf("role" to "assistant", "content" to gson.toJson(action)) + } + "system" -> { + val content = message.content + val shouldKeep = content.startsWith("Intent failed:") || + content.startsWith("Loop detected") || + content.startsWith("Execution Exception:") || + content.startsWith("Error occurred:") || + content.startsWith("AI Request Failed:") || + (content.startsWith("Action success.") && content.contains("\n")) + if (shouldKeep) { + mapOf("role" to "user", "content" to "System feedback: $content") + } else { + null + } + } + else -> null + } + } + } +} diff --git a/app/src/main/java/com/andforce/andclaw/AgentDebugReceiver.kt b/app/src/main/java/com/andforce/andclaw/AgentDebugReceiver.kt new file mode 100644 index 0000000..4728739 --- /dev/null +++ b/app/src/main/java/com/andforce/andclaw/AgentDebugReceiver.kt @@ -0,0 +1,38 @@ +package com.andforce.andclaw + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.Toast + +class AgentDebugReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_START_AGENT -> { + val prompt = intent.getStringExtra(EXTRA_PROMPT)?.trim().orEmpty() + if (prompt.isBlank()) { + Log.w(TAG, "Ignored empty debug prompt") + return + } + + Log.d(TAG, "Starting agent from broadcast: $prompt") + AgentController.startAgent(prompt) + Toast.makeText(context, "Andclaw started: $prompt", Toast.LENGTH_SHORT).show() + } + + ACTION_STOP_AGENT -> { + Log.d(TAG, "Stopping agent from broadcast") + AgentController.stopAgent() + Toast.makeText(context, "Andclaw stopped", Toast.LENGTH_SHORT).show() + } + } + } + + companion object { + private const val TAG = "AgentDebugReceiver" + const val ACTION_START_AGENT = "com.andforce.andclaw.action.START_AGENT" + const val ACTION_STOP_AGENT = "com.andforce.andclaw.action.STOP_AGENT" + const val EXTRA_PROMPT = "prompt" + } +} diff --git a/app/src/main/java/com/andforce/andclaw/App.kt b/app/src/main/java/com/andforce/andclaw/App.kt index ce9196d..6a06521 100644 --- a/app/src/main/java/com/andforce/andclaw/App.kt +++ b/app/src/main/java/com/andforce/andclaw/App.kt @@ -3,6 +3,7 @@ package com.andforce.andclaw import android.app.admin.DevicePolicyManager import android.util.Log import com.afwsamples.testdpc.KoinApplication +import com.afwsamples.testdpc.policy.locktask.AndclawDeviceOwnerBootstrap import com.afwsamples.testdpc.policy.locktask.KioskModeHelper import com.afwsamples.testdpc.policy.locktask.viewmodule.KioskViewModule import com.andforce.andclaw.service.impl.AppInfoService @@ -67,6 +68,10 @@ class App : KoinApplication() { devicePolicyManager.setPermissionPolicy(adminComponentName, DevicePolicyManager.PERMISSION_POLICY_AUTO_GRANT) } + val bootstrapResult = AndclawDeviceOwnerBootstrap.apply(this@App) + if (bootstrapResult.failures.isNotEmpty()) { + Log.w(TAG, "bootstrap failures: ${bootstrapResult.failures}") + } KioskModeHelper.setDefaultKioskPolicies(isDeviceOwner) } diff --git a/app/src/main/java/com/andforce/andclaw/ChatHistoryActivity.kt b/app/src/main/java/com/andforce/andclaw/ChatHistoryActivity.kt index 3991bc4..e9111f6 100644 --- a/app/src/main/java/com/andforce/andclaw/ChatHistoryActivity.kt +++ b/app/src/main/java/com/andforce/andclaw/ChatHistoryActivity.kt @@ -1,10 +1,13 @@ package com.andforce.andclaw import android.os.Bundle +import android.view.inputmethod.EditorInfo import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.andforce.andclaw.databinding.ActivityChatHistoryBinding +import com.andforce.andclaw.model.AgentUiState import com.andforce.andclaw.view.ChatAdapter import kotlinx.coroutines.launch @@ -12,6 +15,7 @@ class ChatHistoryActivity : AppCompatActivity() { private lateinit var binding: ActivityChatHistoryBinding private lateinit var chatAdapter: ChatAdapter + private var currentUiState = AgentUiState() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -19,7 +23,9 @@ class ChatHistoryActivity : AppCompatActivity() { setContentView(binding.root) setupChatList() + setupComposer() observeMessages() + observeUiState() } private fun setupChatList() { @@ -30,6 +36,31 @@ class ChatHistoryActivity : AppCompatActivity() { } } + private fun setupComposer() { + binding.btnStartStop.setOnClickListener { + if (currentUiState.isRunning) { + AgentController.stopAgent() + } else { + submitPrompt() + } + } + + binding.etPrompt.doAfterTextChanged { + updateComposer() + } + + binding.etPrompt.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEND && !currentUiState.isRunning) { + submitPrompt() + true + } else { + false + } + } + + updateComposer() + } + private fun observeMessages() { lifecycleScope.launch { AgentController.messages.collect { messageList -> @@ -40,4 +71,36 @@ class ChatHistoryActivity : AppCompatActivity() { } } } + + private fun observeUiState() { + lifecycleScope.launch { + AgentController.uiStateFlow.collect { state -> + currentUiState = state + updateComposer() + } + } + } + + private fun submitPrompt() { + val prompt = binding.etPrompt.text?.toString()?.trim().orEmpty() + if (prompt.isEmpty()) return + + binding.etPrompt.setText("") + AgentController.startAgent(prompt) + } + + private fun updateComposer() { + val isRunning = currentUiState.isRunning + val hasPrompt = !binding.etPrompt.text.isNullOrBlank() + + binding.etPrompt.isEnabled = !isRunning + binding.inputPromptLayout.isEnabled = !isRunning + binding.btnStartStop.text = if (isRunning) "停止" else "发送" + binding.btnStartStop.isEnabled = isRunning || hasPrompt + binding.tvAgentStatus.text = if (isRunning) { + "运行中: ${currentUiState.userInput}" + } else { + "本地输入任务,直接在手机上启动 Agent" + } + } } diff --git a/app/src/main/java/com/andforce/andclaw/Utils.kt b/app/src/main/java/com/andforce/andclaw/Utils.kt index bad7f7c..7ac0b19 100644 --- a/app/src/main/java/com/andforce/andclaw/Utils.kt +++ b/app/src/main/java/com/andforce/andclaw/Utils.kt @@ -25,7 +25,93 @@ import java.net.SocketTimeoutException import java.util.concurrent.TimeUnit object Utils { - fun buildAgentSystemPrompt(userGoal: String, isDeviceOwner: Boolean): String { + fun buildAgentSystemPrompt( + userGoal: String, + isDeviceOwner: Boolean, + safeMode: Boolean = false + ): String { + if (safeMode) { + return """ +You are a safe Android UI navigation assistant for benign, user-visible tasks. +You MUST respond with a single JSON object ONLY. No text, no markdown, no explanation outside the JSON. + +Goal: "$userGoal" + +You can help with these safe tasks only: +- open a visible app or web page +- tap visible UI elements +- scroll the current page +- enter text into the active input field +- go back or go home +- wait for loading to finish +- take a screenshot +- finish when the goal is complete + +Allowed action types: +1. "intent" +2. "click" +3. "swipe" +4. "text_input" +5. "global_action" +6. "wait" +7. "screenshot" +8. "finish" + +Strict safety limits: +- Never suggest or mention phone calls, SMS, sharing, downloads, camera, screen recording, volume control, device policy, app install/uninstall, passwords, USB, reboot, wipe, or factory reset. +- If the goal would require any unsupported or risky action, return "finish" with a short reason instead of trying. +- Focus on public browsing, reading, simple app navigation, and screenshots. + +Allowed intent examples: +- Open URL: {"type":"intent","action":"android.intent.action.VIEW","data":"https://example.com"} +- Launch app by package: {"type":"intent","action":"android.intent.action.MAIN","package_name":"com.android.browser"} + +Allowed global actions: +- {"type":"global_action","global_action":"back"} +- {"type":"global_action","global_action":"home"} + +Swipe examples: +- Scroll down: {"type":"swipe","x":540,"y":1600,"end_x":540,"end_y":700,"duration":350} +- Scroll up: {"type":"swipe","x":540,"y":700,"end_x":540,"end_y":1600,"duration":350} + +Text input example: +- {"type":"text_input","text":"Andclaw GitHub"} + +Browser / WebView note: +- If the current screen is a browser or web page, use visible text and the provided screen data to pick the next UI action. +- To search, click the search box first, then use "text_input". + +Rules: +1. Use "intent" first if it directly opens the needed app or page. +2. Use "click" only for visible UI targets from the current screen state. +3. Use "swipe" for scrolling or tab/page changes. +4. Use "wait" when the page is loading. +5. Use "screenshot" when the user asks to capture the current result. +6. Use "finish" only when the goal is complete or clearly blocked by the safety limits. +7. Write "progress" and "reason" in the same language as the user's goal. + +Output schema: +{ + "progress": "Steps completed so far", + "reason": "Why this step is needed", + "type": "intent | click | swipe | text_input | global_action | wait | screenshot | finish", + "action": "intent action string (for intent type)", + "data": "URI string (for intent type)", + "x": 0, + "y": 0, + "end_x": 0, + "end_y": 0, + "duration": 0, + "text": "text to input (for text_input type)", + "global_action": "back|home", + "package_name": "target package (for intent type)", + "class_name": "target activity class (optional)" +} + +CRITICAL: Your entire response must be parseable as JSON. Any non-JSON text will cause a system error. +""".trimIndent() + } + val dpmSection = if (isDeviceOwner) """ === DPM (Device Policy Manager) - Device Owner Only === @@ -117,7 +203,8 @@ Set alarm: action:"android.intent.action.SET_ALARM", extras:{"android.intent.ext Dial: action:"android.intent.action.DIAL", data:"tel:10086" SMS: action:"android.intent.action.SENDTO", data:"smsto:10086" Share: action:"android.intent.action.SEND", extras:{"android.intent.extra.TEXT":"hello"} -Settings: action:"android.provider.Settings.ACTION_WIFI_SETTINGS" +Wi-Fi settings: action:"android.settings.WIFI_SETTINGS" +Display settings: action:"android.settings.DISPLAY_SETTINGS" Open downloads list: action:"android.intent.action.VIEW_DOWNLOADS" Launch specific app: action:"android.intent.action.MAIN", package_name:"com.example", class_name:"com.example.MainActivity" Launch by package only: action:"android.intent.action.MAIN", package_name:"com.example" @@ -177,6 +264,13 @@ IMPORTANT: After "start_record", a system dialog appears asking for permission. Example: {"type":"screen_record","screen_record_action":"start_record","progress":"准备录屏","reason":"用户要求录制屏幕"} Example: {"type":"screen_record","screen_record_action":"stop_record","progress":"停止录屏","reason":"用户要求停止录屏"} +=== IMPORTANT INTENT STRING EXAMPLES === +Use the ACTUAL Android action string value, not the Java constant name. +Correct: "android.settings.DISPLAY_SETTINGS" +Wrong: "android.provider.Settings.ACTION_DISPLAY_SETTINGS" +Correct: "android.settings.WIFI_SETTINGS" +Wrong: "android.provider.Settings.ACTION_WIFI_SETTINGS" + === VOLUME === Control device volume. Use "volume_action" field with one of: - "set" — Set volume to a percentage (0-100). Use extras: {"level": 50, "stream": "music"} @@ -344,8 +438,12 @@ CRITICAL: Your entire response must be parseable as JSON. Any non-JSON text will .writeTimeout(60, TimeUnit.SECONDS) .build() - val systemPrompt = buildAgentSystemPrompt(userGoal, isDeviceOwner) val isKimi = config.provider.equals("Kimi Code", ignoreCase = true) + val systemPrompt = buildAgentSystemPrompt( + userGoal = userGoal, + isDeviceOwner = isDeviceOwner, + safeMode = isKimi + ) val screenHint = if (screenshotBase64 != null) { val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @@ -472,4 +570,4 @@ Respond with JSON only.""" Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/activity_chat_history.xml b/app/src/main/res/layout/activity_chat_history.xml index 24c5a40..acab07c 100644 --- a/app/src/main/res/layout/activity_chat_history.xml +++ b/app/src/main/res/layout/activity_chat_history.xml @@ -22,5 +22,50 @@ android:clipToPadding="false" android:padding="8dp" /> + + + + + + + + + + + diff --git a/app/src/test/java/com/andforce/andclaw/AgentConversationContextTest.kt b/app/src/test/java/com/andforce/andclaw/AgentConversationContextTest.kt new file mode 100644 index 0000000..081c57e --- /dev/null +++ b/app/src/test/java/com/andforce/andclaw/AgentConversationContextTest.kt @@ -0,0 +1,47 @@ +package com.andforce.andclaw + +import com.andforce.andclaw.model.AiAction +import com.andforce.andclaw.model.ChatMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class AgentConversationContextTest { + @Test + fun buildHistory_ignoresMessagesFromPreviousSessions() { + val messages = listOf( + ChatMessage(role = "user", content = "Open Settings"), + ChatMessage(role = "ai", content = "Opening", action = AiAction(type = AiAction.TYPE_CLICK, x = 10, y = 20)), + ChatMessage(role = "system", content = "Finished."), + ChatMessage(role = "user", content = "Go home and tap Settings") + ) + + val history = AgentConversationContext.buildHistory(messages, sessionStartIndex = 3) + + assertEquals(1, history.size) + assertEquals("user", history[0]["role"]) + assertEquals("Go home and tap Settings", history[0]["content"]) + } + + @Test + fun buildHistory_keepsRelevantSystemFeedbackWithinCurrentSession() { + val messages = listOf( + ChatMessage(role = "user", content = "Open Settings"), + ChatMessage( + role = "ai", + content = "Trying intent", + action = AiAction(type = AiAction.TYPE_INTENT, action = "android.settings.SETTINGS") + ), + ChatMessage(role = "system", content = "Intent failed: No Activity found") + ) + + val history = AgentConversationContext.buildHistory(messages, sessionStartIndex = 0) + + assertEquals(3, history.size) + assertEquals("user", history[0]["role"]) + assertEquals("assistant", history[1]["role"]) + assertEquals("user", history[2]["role"]) + assertEquals("System feedback: Intent failed: No Activity found", history[2]["content"]) + assertFalse(history[1]["content"].isNullOrBlank()) + } +} diff --git a/app/src/test/java/com/andforce/andclaw/UtilsPromptTest.kt b/app/src/test/java/com/andforce/andclaw/UtilsPromptTest.kt new file mode 100644 index 0000000..93ea9a9 --- /dev/null +++ b/app/src/test/java/com/andforce/andclaw/UtilsPromptTest.kt @@ -0,0 +1,35 @@ +package com.andforce.andclaw + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UtilsPromptTest { + + @Test + fun safeModePromptKeepsOnlyBenignActions() { + val prompt = Utils.buildAgentSystemPrompt( + userGoal = "打开浏览器查看公开网页并截图", + isDeviceOwner = true, + safeMode = true + ) + + assertTrue(prompt.contains("\"intent\"")) + assertTrue(prompt.contains("\"click\"")) + assertTrue(prompt.contains("\"swipe\"")) + assertTrue(prompt.contains("\"text_input\"")) + assertTrue(prompt.contains("\"global_action\"")) + assertTrue(prompt.contains("\"wait\"")) + assertTrue(prompt.contains("\"screenshot\"")) + assertTrue(prompt.contains("\"finish\"")) + + assertFalse(prompt.contains("\"camera\"")) + assertFalse(prompt.contains("\"screen_record\"")) + assertFalse(prompt.contains("\"download\"")) + assertFalse(prompt.contains("\"volume\"")) + assertFalse(prompt.contains("\"dpm\"")) + assertFalse(prompt.contains("android.intent.action.DIAL")) + assertFalse(prompt.contains("android.intent.action.SENDTO")) + assertTrue(prompt.contains("factory reset")) + } +} diff --git a/docs/device-owner/README.md b/docs/device-owner/README.md new file mode 100644 index 0000000..a624022 --- /dev/null +++ b/docs/device-owner/README.md @@ -0,0 +1,99 @@ +# Device Owner 落地指南 + +本目录把实机上 `Andclaw` 单包 `Device Owner` 落地所需的文档和脚本整理回正式仓库,替代之前散落在临时目录里的 reset kit。 + +## 目录结构 + +- `docs/device-owner/README.md` + - Device Owner 落地主入口。 +- `docs/device-owner/xiaomi-miui.md` + - 小米 / MIUI 现场踩坑与修复办法。 +- `tools/device-owner/*.sh` + - 通过 ADB 完成安装、激活、修权限、验收、重启复验和基线采集。 + +## 已验证链路 + +这套流程已经在实机上验证过以下结果: + +- 单包包名为 `com.andforce.andclaw` +- `dpm list-owners` 返回 `com.andforce.andclaw/.DeviceAdminReceiver,DeviceOwner` +- 重启后 owner 状态保持 +- 通过 `AgentDebugReceiver` 下发 `截一张当前屏幕并结束任务` 可以完整结束任务,并在 `Pictures/Andclaw/` 产出截图 + +## 仓库内约定 + +- 将签名后的单包 APK 放到 `out/device-owner/andclaw-single-owner-signed.apk` +- 或者在运行脚本时显式传入 APK 路径 +- 或者通过环境变量 `ANDCLAW_DEVICE_OWNER_APK=/abs/path/to/app.apk` 覆盖默认路径 +- 所有脚本输出默认都落到 `out/device-owner/` + +以下产物不入仓库: + +- keystore / `*.keystore` / `*.jks` +- 签名后的 APK 二进制 +- 大体积录屏 / 演示视频 +- 现场一次性的日志快照目录 + +## 手机侧最小动作 + +恢复出厂后,手机侧只需要做到这里: + +1. 不登录任何账号,不恢复备份 +2. 进入桌面 +3. 打开开发者模式 +4. 开启 USB 调试 +5. 插回数据线,并允许当前电脑的 ADB 授权 + +完成后就可以交给脚本继续。 + +## 电脑侧执行顺序 + +先诊断当前状态: + +```bash +tools/device-owner/00-diagnose-reset-state.sh +``` + +一键跑完整引导: + +```bash +tools/device-owner/40-bootstrap-after-reset.sh +``` + +如需显式指定 APK: + +```bash +tools/device-owner/40-bootstrap-after-reset.sh /abs/path/to/andclaw-single-owner-signed.apk +``` + +单步执行时推荐顺序: + +```bash +tools/device-owner/10-provision-single-owner.sh +tools/device-owner/20-repair-single-owner-runtime.sh +tools/device-owner/30-verify-single-owner.sh +tools/device-owner/35-reboot-and-reverify.sh +``` + +如果需要保留一次完整的 agent 基线输出: + +```bash +tools/device-owner/50-capture-agent-baseline.sh +``` + +## 核验标准 + +至少应满足: + +- `tools/device-owner/00-diagnose-reset-state.sh` 中 owner 正确 +- `tools/device-owner/30-verify-single-owner.sh` 中 smoke test 输出 `[system]: Finished.` +- 最新截图出现在 `/sdcard/Pictures/Andclaw/` + +## 小米 / MIUI 注意事项 + +如果设备是小米 / MIUI,请先看 [xiaomi-miui.md](./xiaomi-miui.md): + +- `adb install` 可能被 `USB安装提示` 阻断 +- `dpm set-device-owner` 可能被系统账号拦截,即使刚恢复出厂 + +这些问题都已经有可复现的处理办法。 diff --git a/docs/device-owner/xiaomi-miui.md b/docs/device-owner/xiaomi-miui.md new file mode 100644 index 0000000..ecc4f0c --- /dev/null +++ b/docs/device-owner/xiaomi-miui.md @@ -0,0 +1,59 @@ +# Xiaomi / MIUI 现场记录 + +以下问题是在小米设备上做单包 `Device Owner` 落地时真实遇到并解决过的。 + +## 1. `adb install` 被 `USB安装提示` 卡住 + +### 现象 + +- `adb install -r -d ...` 卡住或失败 +- 手机弹出系统对话框: + - `USB安装提示` + - `正在通过USB安装此应用,是否继续?` + +### 处理办法 + +- 直接在手机上点 `继续安装` +- 如果是无人值守工装场景,可以临时用 ADB 点击该按钮,但坐标依赖机型分辨率 + +本次验证设备上,`继续安装` 的按钮中心大约在 `(320, 2080)`。这个坐标只应视为现场样例,不能直接当成通用值。 + +## 2. `dpm set-device-owner` 提示已有账号 + +### 现象 + +即使恢复出厂后,仍可能报错: + +```text +Not allowed to set the device owner because there are already some accounts on the device. +``` + +### 根因 + +小米系统账号仍然占据了 user 0 的 account state,导致 Android 拒绝设置 `Device Owner`。 + +### 已验证修复 + +先临时卸载 user 0 上的小米账号包: + +```bash +adb shell pm uninstall --user 0 com.xiaomi.account +``` + +确认 `Accounts: 0` 后,再执行: + +```bash +adb shell dpm set-device-owner com.andforce.andclaw/.DeviceAdminReceiver +``` + +设置成功后,再把系统包恢复回来: + +```bash +adb shell cmd package install-existing com.xiaomi.account +``` + +### 注意 + +- 这是工装阶段的补救动作,只建议在刚恢复出厂、尚未登录账号的设备上使用 +- 恢复系统包后,本次验证里账号数量仍保持 `0`,并未影响已经建立的 `Device Owner` +- 若后续手机厂商 ROM 行为变化,应以 `tools/device-owner/00-diagnose-reset-state.sh` 的实时输出为准 diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index b51d008..89fad37 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -9,5 +9,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff54025 toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect -toolchainVendor=JETBRAINS toolchainVersion=21 diff --git a/mdm/build.gradle b/mdm/build.gradle index fb25f31..333bd98 100644 --- a/mdm/build.gradle +++ b/mdm/build.gradle @@ -94,8 +94,6 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' testImplementation 'org.robolectric:robolectric:4.11.1' - testImplementation 'org.robolectric:robolectric-annotations:4.6.1' - testImplementation 'org.robolectric:shadows-core:4.6.1' // Gson diff --git a/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawAgentReadiness.kt b/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawAgentReadiness.kt new file mode 100644 index 0000000..461031b --- /dev/null +++ b/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawAgentReadiness.kt @@ -0,0 +1,256 @@ +package com.afwsamples.testdpc.policy.locktask + +import android.Manifest +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.AppOpsManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.PowerManager +import android.os.Process +import android.provider.Settings +import android.text.TextUtils +import android.view.accessibility.AccessibilityManager +import androidx.core.content.ContextCompat + +internal object AndclawAgentReadiness { + enum class Requirement( + val title: String, + val requiredForLaunch: Boolean, + val actionLabel: String? + ) { + ACCESSIBILITY("辅助功能", true, "开启辅助功能"), + FILE_ACCESS("文件访问", true, "允许所有文件访问"), + OVERLAY("悬浮窗", true, "允许悬浮窗"), + RUNTIME_PERMISSIONS("运行时权限", false, "打开应用权限"), + USAGE_STATS("使用情况访问", false, "允许使用情况访问"), + BATTERY_OPTIMIZATION("电池白名单", false, "加入电池白名单") + } + + data class Item( + val requirement: Requirement, + val granted: Boolean, + val detail: String + ) { + val actionLabel: String? + get() = if (granted) null else requirement.actionLabel + } + + data class Snapshot( + val items: List + ) { + val readyForLaunch: Boolean + get() = items.none { it.requirement.requiredForLaunch && !it.granted } + + val summaryText: String + get() { + val requiredItems = items.filter { it.requirement.requiredForLaunch } + val recommendedItems = items.filterNot { it.requirement.requiredForLaunch } + val requiredReady = requiredItems.count { it.granted } + val recommendedReady = recommendedItems.count { it.granted } + val requiredTotal = requiredItems.size + val recommendedTotal = recommendedItems.size + + return when { + readyForLaunch && recommendedItems.all { it.granted } -> + "Agent 已进入完整就绪状态,核心权限和建议优化均已完成。" + + readyForLaunch -> + "Agent 核心权限已就绪,建议优化已完成 $recommendedReady/$recommendedTotal。" + + else -> + "Agent 核心权限已就绪 $requiredReady/$requiredTotal,仍需补齐后才能稳定进入对话页。" + } + } + + val detailText: String + get() = items.joinToString("\n") { item -> + val marker = if (item.granted) "[OK]" else "[ ]" + "$marker ${item.requirement.title}: ${item.detail}" + } + + fun nextActionableItem(): Item? { + return items.firstOrNull { it.requirement.requiredForLaunch && !it.granted && it.actionLabel != null } + ?: items.firstOrNull { !it.requirement.requiredForLaunch && !it.granted && it.actionLabel != null } + } + } + + fun inspect(context: Context): Snapshot { + val runtimeMissing = runtimePermissionsMissing(context) + + return Snapshot( + listOf( + Item( + requirement = Requirement.ACCESSIBILITY, + granted = isAccessibilityServiceEnabled(context) && isAccessibilityServiceConnected(context), + detail = accessibilityDetail(context) + ), + Item( + requirement = Requirement.FILE_ACCESS, + granted = Environment.isExternalStorageManager(), + detail = if (Environment.isExternalStorageManager()) { + "已允许访问下载目录和外部文件。" + } else { + "未开启,静默安装和文件读写会受限。" + } + ), + Item( + requirement = Requirement.OVERLAY, + granted = Settings.canDrawOverlays(context), + detail = if (Settings.canDrawOverlays(context)) { + "已允许显示悬浮急停按钮。" + } else { + "未开启,运行中无法稳定显示紧急停止按钮。" + } + ), + Item( + requirement = Requirement.RUNTIME_PERMISSIONS, + granted = runtimeMissing.isEmpty(), + detail = if (runtimeMissing.isEmpty()) { + "相机和麦克风权限已到位。" + } else { + "仍缺少: ${runtimeMissing.joinToString("、") { permissionLabel(it) }}" + } + ), + Item( + requirement = Requirement.USAGE_STATS, + granted = isUsageStatsGranted(context), + detail = if (isUsageStatsGranted(context)) { + "已允许读取使用情况。" + } else { + "未开启,后续应用切换与前台状态分析能力会受限。" + } + ), + Item( + requirement = Requirement.BATTERY_OPTIMIZATION, + granted = isIgnoringBatteryOptimizations(context), + detail = if (isIgnoringBatteryOptimizations(context)) { + "已加入电池优化白名单。" + } else { + "未加入,系统可能在后台杀死长任务。" + } + ) + ) + ) + } + + fun buildActionIntent(context: Context, requirement: Requirement): Intent? { + val packageUri = Uri.parse("package:${context.packageName}") + return when (requirement) { + Requirement.ACCESSIBILITY -> + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + + Requirement.FILE_ACCESS -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, packageUri) + } else { + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + } + + Requirement.OVERLAY -> + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri) + + Requirement.RUNTIME_PERMISSIONS -> + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri) + + Requirement.USAGE_STATS -> + Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) + + Requirement.BATTERY_OPTIMIZATION -> + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri) + } + } + + private fun accessibilityDetail(context: Context): String { + return when { + isAccessibilityServiceEnabled(context) && isAccessibilityServiceConnected(context) -> + "已开启并连接到 Andclaw 服务。" + + isAccessibilityServiceEnabled(context) -> + "已授权,但系统尚未把服务连接起来。" + + else -> + "未开启,Agent 无法读取屏幕和执行点击。" + } + } + + private fun isAccessibilityServiceEnabled(context: Context): Boolean { + val targetComponent = ComponentName( + context.packageName, + "com.andforce.andclaw.AgentAccessibilityService" + ).flattenToString() + val enabledServices = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + val splitter = TextUtils.SimpleStringSplitter(':') + splitter.setString(enabledServices) + while (splitter.hasNext()) { + if (splitter.next().equals(targetComponent, ignoreCase = true)) { + return true + } + } + return false + } + + private fun isAccessibilityServiceConnected(context: Context): Boolean { + val targetComponent = ComponentName( + context.packageName, + "com.andforce.andclaw.AgentAccessibilityService" + ).flattenToString() + val accessibilityManager = + context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager ?: return false + return accessibilityManager + .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + .any { serviceInfo -> + serviceInfo.resolveInfo.serviceInfo?.let { info -> + ComponentName(info.packageName, info.name).flattenToString() + } == targetComponent + } + } + + private fun runtimePermissionsMissing(context: Context): List { + val permissions = listOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ) + return permissions.filter { permission -> + ContextCompat.checkSelfPermission(context, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED + } + } + + private fun isUsageStatsGranted(context: Context): Boolean { + val appOps = context.getSystemService(AppOpsManager::class.java) ?: return false + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_GET_USAGE_STATS, + Process.myUid(), + context.packageName + ) + } else { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_GET_USAGE_STATS, + Process.myUid(), + context.packageName + ) + } + return mode == AppOpsManager.MODE_ALLOWED + } + + private fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val powerManager = context.getSystemService(PowerManager::class.java) ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + private fun permissionLabel(permission: String): String { + return when (permission) { + Manifest.permission.CAMERA -> "相机" + Manifest.permission.RECORD_AUDIO -> "麦克风" + else -> permission.substringAfterLast('.') + } + } +} diff --git a/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawDeviceOwnerBootstrap.kt b/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawDeviceOwnerBootstrap.kt new file mode 100644 index 0000000..28f6f98 --- /dev/null +++ b/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/AndclawDeviceOwnerBootstrap.kt @@ -0,0 +1,82 @@ +package com.afwsamples.testdpc.policy.locktask + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.util.Log +import com.andforce.andclaw.DeviceAdminReceiver +import com.andforce.mdm.center.AppUtils + +object AndclawDeviceOwnerBootstrap { + private const val TAG = "AndclawBootstrap" + + data class Result( + val appliedSteps: List, + val failures: List + ) { + fun userSummary(): String { + if (appliedSteps.isEmpty() && failures.isEmpty()) { + return "没有可执行的自动修复项。" + } + + return buildString { + if (appliedSteps.isNotEmpty()) { + append("已检查并补齐: ${appliedSteps.joinToString("、")}") + } + if (failures.isNotEmpty()) { + if (isNotEmpty()) append(";") + append("失败: ${failures.joinToString("、")}") + } + } + } + } + + fun apply(context: Context): Result { + val devicePolicyManager = + context.getSystemService(Context.DEVICE_POLICY_SERVICE) as? DevicePolicyManager + ?: return Result(emptyList(), listOf("DevicePolicyManager 不可用")) + val adminComponent = DeviceAdminReceiver.getComponentName(context) + ?: return Result(emptyList(), listOf("Device Owner 组件不可用")) + + val applied = mutableListOf() + val failures = mutableListOf() + + runStep("自动授权策略", failures) { + devicePolicyManager.setPermissionPolicy( + adminComponent, + DevicePolicyManager.PERMISSION_POLICY_AUTO_GRANT + ) + applied += "自动授权策略" + } + + runStep("运行时权限", failures) { + AppUtils.enablePermission(context, context.packageName) + applied += "运行时权限" + } + + runStep("辅助功能白名单", failures) { + val permitted = devicePolicyManager.getPermittedAccessibilityServices(adminComponent) + if (permitted != null && !permitted.contains(context.packageName)) { + devicePolicyManager.setPermittedAccessibilityServices( + adminComponent, + permitted + context.packageName + ) + } + applied += "辅助功能白名单" + } + + return Result(applied, failures) + } + + private fun runStep( + name: String, + failures: MutableList, + block: () -> Unit + ) { + try { + block() + } catch (t: Throwable) { + Log.w(TAG, "Bootstrap step failed: $name", t) + failures += "$name(${t.message ?: "unknown"})" + } + } +} diff --git a/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/SetupKioskModeActivity.kt b/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/SetupKioskModeActivity.kt index fbbbe0a..060a044 100644 --- a/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/SetupKioskModeActivity.kt +++ b/mdm/src/main/java/com/afwsamples/testdpc/policy/locktask/SetupKioskModeActivity.kt @@ -1,6 +1,5 @@ package com.afwsamples.testdpc.policy.locktask -import android.accessibilityservice.AccessibilityServiceInfo import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Intent @@ -8,60 +7,41 @@ import android.content.pm.PackageManager import android.location.LocationManager import android.net.ConnectivityManager import android.os.Bundle -import android.os.Environment import android.os.UserManager import android.provider.Settings -import android.text.TextUtils import android.view.View -import android.view.accessibility.AccessibilityManager import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import com.andforce.andclaw.DeviceAdminReceiver -import com.andforce.mdm.center.DeviceStatusViewModel -import com.andforce.mdm.center.AppUtils import com.afwsamples.testdpc.DevicePolicyManagerGateway import com.afwsamples.testdpc.DevicePolicyManagerGatewayImpl import com.afwsamples.testdpc.databinding.ActivitySetupKioskLayoutBinding import com.afwsamples.testdpc.policy.locktask.viewmodule.KioskViewModule -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.andforce.andclaw.DeviceAdminReceiver +import com.andforce.mdm.center.AppUtils +import com.andforce.mdm.center.DeviceStatusViewModel import com.base.services.ITgBridgeService +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel open class SetupKioskModeActivity : AppCompatActivity() { private var mAdminComponentName: ComponentName? = null - private var mDevicePolicyManager: DevicePolicyManager? = null private var mPackageManager: PackageManager? = null - private var binding: ActivitySetupKioskLayoutBinding? = null - - - private val kioskViewModule: KioskViewModule by viewModel() - private var mDevicePolicyManagerGateway: DevicePolicyManagerGateway? = null private var mUserManager: UserManager? = null - private var connectivityManager: ConnectivityManager? = null - private var usbEnableDebugAlertDialog: AlertDialog? = null - private var permissionGuideDialog: AlertDialog? = null + private var latestReadiness: AndclawAgentReadiness.Snapshot? = null + private val kioskViewModule: KioskViewModule by viewModel() private val deviceStatusViewModel: DeviceStatusViewModel by inject() - private val tgBridgeService: ITgBridgeService by inject() - private var appsActivityClickCount = 0 - private var lastAppsClickTime = 0L - - - private companion object { - const val APPS_CLICK_TIMEOUT = 5000L // 5秒内需要完成5次点击 - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -72,13 +52,11 @@ open class SetupKioskModeActivity : AppCompatActivity() { binding = ActivitySetupKioskLayoutBinding.inflate(layoutInflater) binding?.let { binding -> setContentView(binding.root) - - // 设置网络按钮点击事件 + binding.setupNetwork.setOnClickListener { startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) } - - // 修改设备管理员按钮点击事件 + binding.setupDeviceOwner.setOnClickListener { if (kioskViewModule.deviceOwnerStateFlow.value) { showRemoveDeviceOwnerDialog() @@ -87,6 +65,18 @@ open class SetupKioskModeActivity : AppCompatActivity() { } } + binding.permissionPrimaryAction.setOnClickListener { + openNextPermissionAction() + } + + binding.permissionAutoFix.setOnClickListener { + runDeviceOwnerAutoFix(userInitiated = true) + } + + binding.refreshAgentPermissions.setOnClickListener { + refreshAgentReadiness(showToast = true) + } + binding.openChatActivity.setOnClickListener { openChatActivity() } @@ -98,15 +88,13 @@ open class SetupKioskModeActivity : AppCompatActivity() { binding.openAiSettings.setOnClickListener { openAiSettings() } - } connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - // 监听网络状态 lifecycleScope.launch { - connectivityManager?.let { connectivityManager -> - deviceStatusViewModel.observeNetworkState(connectivityManager).collect { isConnected -> + connectivityManager?.let { manager -> + deviceStatusViewModel.observeNetworkState(manager).collect { isConnected -> binding?.apply { networkStatus.text = if (isConnected) "已连接" else "未连接" setupNetwork.visibility = if (!isConnected) View.VISIBLE else View.GONE @@ -115,7 +103,6 @@ open class SetupKioskModeActivity : AppCompatActivity() { } } - // 如果网络已经链接,设置状态 if (deviceStatusViewModel.isNetworkConnected(connectivityManager)) { binding?.apply { networkStatus.text = "已连接" @@ -123,12 +110,10 @@ open class SetupKioskModeActivity : AppCompatActivity() { } } - // 监听设备管理员状态 lifecycleScope.launch { kioskViewModule.deviceOwnerStateFlow.collect { isDeviceOwner -> if (isDeviceOwner) { mAdminComponentName = DeviceAdminReceiver.getComponentName(this@SetupKioskModeActivity) - mDevicePolicyManagerGateway = DevicePolicyManagerGatewayImpl( mDevicePolicyManager!!, @@ -138,8 +123,8 @@ open class SetupKioskModeActivity : AppCompatActivity() { mAdminComponentName ) usbEnableDebugAlertDialog?.dismiss() - tgBridgeService.startBridge() + runDeviceOwnerAutoFix(userInitiated = false) } else { tgBridgeService.stopBridge() } @@ -148,16 +133,110 @@ open class SetupKioskModeActivity : AppCompatActivity() { deviceOwnerStatus.text = if (isDeviceOwner) "已开启" else "未开启" setupDeviceOwner.text = if (isDeviceOwner) "移除设备管理员" else "设置设备管理员" setupDeviceOwner.visibility = View.VISIBLE + permissionAutoFix.visibility = if (isDeviceOwner) View.VISIBLE else View.GONE } + refreshAgentReadiness() } } - } override fun onResume() { super.onResume() - checkRequiredPermissions() + if (kioskViewModule.deviceOwnerStateFlow.value) { + runDeviceOwnerAutoFix(userInitiated = false) + } + if (maybeOpenChatOnLauncherEntry()) { + return + } + refreshAgentReadiness() + } + + private fun maybeOpenChatOnLauncherEntry(): Boolean { + val launchIntent = intent ?: return false + val isLauncherEntry = launchIntent.action == Intent.ACTION_MAIN && + launchIntent.hasCategory(Intent.CATEGORY_LAUNCHER) + if (!isLauncherEntry || !hasCoreAgentPrerequisites()) { + return false + } + openChatActivity(finishAfterLaunch = true) + return true + } + + private fun hasCoreAgentPrerequisites(): Boolean { + return AndclawAgentReadiness.inspect(this).readyForLaunch + } + + private fun refreshAgentReadiness(showToast: Boolean = false) { + val snapshot = AndclawAgentReadiness.inspect(this) + latestReadiness = snapshot + val nextAction = snapshot.nextActionableItem() + binding?.apply { + agentPermissionSummary.text = snapshot.summaryText + agentPermissionDetails.text = snapshot.detailText + permissionPrimaryAction.text = nextAction?.actionLabel ?: "全部就绪" + permissionPrimaryAction.isEnabled = nextAction != null + } + if (showToast) { + Toast.makeText(this, snapshot.summaryText, Toast.LENGTH_SHORT).show() + } + } + + private fun openNextPermissionAction() { + val target = latestReadiness?.nextActionableItem() + if (target == null) { + Toast.makeText(this, "当前没有待处理的权限项。", Toast.LENGTH_SHORT).show() + return + } + + val intent = AndclawAgentReadiness.buildActionIntent(this, target.requirement) + ?: run { + Toast.makeText(this, "没有可用的设置入口。", Toast.LENGTH_SHORT).show() + return + } + + val fallbackIntent = when (target.requirement) { + AndclawAgentReadiness.Requirement.FILE_ACCESS -> + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + + AndclawAgentReadiness.Requirement.BATTERY_OPTIMIZATION -> + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + + else -> null + } + + try { + startActivity(intent) + } catch (e: Exception) { + try { + if (fallbackIntent != null) { + startActivity(fallbackIntent) + } else { + throw e + } + } catch (inner: Exception) { + Toast.makeText( + this, + "打开设置失败: ${inner.message ?: e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } + + private fun runDeviceOwnerAutoFix(userInitiated: Boolean) { + if (!kioskViewModule.deviceOwnerStateFlow.value) { + if (userInitiated) { + Toast.makeText(this, "需要先开启设备管理员。", Toast.LENGTH_SHORT).show() + } + return + } + + val result = AndclawDeviceOwnerBootstrap.apply(this) + refreshAgentReadiness() + if (userInitiated) { + Toast.makeText(this, result.userSummary(), Toast.LENGTH_LONG).show() + } } private fun showRemoveDeviceOwnerDialog() { @@ -165,24 +244,15 @@ open class SetupKioskModeActivity : AppCompatActivity() { .setTitle("移除设备管理员") .setMessage("确定要移除设备管理员吗?移除后需要重新设置。") .setPositiveButton("确定") { _, _ -> - AppUtils.showAllHideApps(this) mDevicePolicyManagerGateway?.clearDeviceOwnerApp( { - Toast.makeText( - this, - "设备管理员已移除", - Toast.LENGTH_SHORT - ).show() + Toast.makeText(this, "设备管理员已移除", Toast.LENGTH_SHORT).show() kioskViewModule.updateDeviceOwnerState(false) }, { e: Exception? -> - Toast.makeText( - this, - "移除设备管理员失败: $e", - Toast.LENGTH_SHORT - ).show() + Toast.makeText(this, "移除设备管理员失败: $e", Toast.LENGTH_SHORT).show() } ) } @@ -195,12 +265,14 @@ open class SetupKioskModeActivity : AppCompatActivity() { val componentName = DeviceAdminReceiver.getReceiverComponentName(this).flattenToShortString() usbEnableDebugAlertDialog = MaterialAlertDialogBuilder(this) .setTitle("设置设备管理员") - .setMessage("请按照以下步骤操作:\n\n" + + .setMessage( + "请按照以下步骤操作:\n\n" + "1. 打开「设置 > 关于手机」\n" + "2. 连续点击「版本号」7 次开启开发者选项\n" + "3. 在「开发者选项」中开启 USB 调试\n" + "4. 连接电脑,在终端执行以下命令:\n\n" + - "adb shell dpm set-device-owner $componentName") + "adb shell dpm set-device-owner $componentName" + ) .setPositiveButton("打开开发者选项") { _, _ -> startActivity(Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)) } @@ -209,80 +281,17 @@ open class SetupKioskModeActivity : AppCompatActivity() { .show() } - private fun checkRequiredPermissions() { - if (!isAccessibilityServiceEnabled() || !isAccessibilityServiceConnected()) { - showPermissionGuideDialog( - title = "需要开启辅助功能", - message = "Andclaw 需要辅助功能服务来读取屏幕并执行操作,请在设置中找到 Andclaw 并开启。", - intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - ) - return - } - - if (!Environment.isExternalStorageManager()) { - showPermissionGuideDialog( - title = "需要文件访问权限", - message = "Andclaw 需要「所有文件访问」权限来读取下载目录中的 APK 文件并执行静默安装。", - intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - ) - return - } - } - - private fun isAccessibilityServiceEnabled(): Boolean { - val targetComponent = ComponentName( - packageName, - "com.andforce.andclaw.AgentAccessibilityService" - ).flattenToString() - val enabledServices = Settings.Secure.getString( - contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES - ) ?: return false - val splitter = TextUtils.SimpleStringSplitter(':') - splitter.setString(enabledServices) - while (splitter.hasNext()) { - if (splitter.next().equals(targetComponent, ignoreCase = true)) { - return true - } - } - return false - } - - private fun isAccessibilityServiceConnected(): Boolean { - val targetComponent = ComponentName( - packageName, - "com.andforce.andclaw.AgentAccessibilityService" - ).flattenToString() - val accessibilityManager = - getSystemService(ACCESSIBILITY_SERVICE) as? AccessibilityManager ?: return false - return accessibilityManager - .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) - .any { it.resolveInfo.serviceInfo?.let { info -> - ComponentName(info.packageName, info.name).flattenToString() - } == targetComponent } - } - - private fun showPermissionGuideDialog(title: String, message: String, intent: Intent) { - if (permissionGuideDialog?.isShowing == true) { - return - } - permissionGuideDialog = MaterialAlertDialogBuilder(this) - .setTitle(title) - .setMessage(message) - .setCancelable(false) - .setPositiveButton("去设置") { _, _ -> startActivity(intent) } - .setNegativeButton("暂时跳过", null) - .show() - } - private fun openAiSettings() { startActivity(Intent(this, AiSettingsActivity::class.java)) } - private fun openChatActivity() { + private fun openChatActivity(finishAfterLaunch: Boolean = false) { val intent = Intent().setClassName(packageName, "com.andforce.andclaw.ChatHistoryActivity") try { startActivity(intent) + if (finishAfterLaunch) { + finish() + } } catch (e: Exception) { Toast.makeText(this, "启动对话页失败: ${e.message}", Toast.LENGTH_SHORT).show() } @@ -296,5 +305,4 @@ open class SetupKioskModeActivity : AppCompatActivity() { Toast.makeText(this, "启动测试页失败: ${e.message}", Toast.LENGTH_SHORT).show() } } - } diff --git a/mdm/src/main/res/layout/activity_setup_kiosk_layout.xml b/mdm/src/main/res/layout/activity_setup_kiosk_layout.xml index 66f892b..f914b99 100644 --- a/mdm/src/main/res/layout/activity_setup_kiosk_layout.xml +++ b/mdm/src/main/res/layout/activity_setup_kiosk_layout.xml @@ -116,6 +116,68 @@ + + + + + + + + + + + + + + + + + + + &2 + exit 1 +fi + +print_header "Wait For Boot" +wait_for_boot + +print_header "Install APK" +if ! install_out="$(adb_cmd install -r -d "$APK" 2>&1)"; then + printf '%s\n' "$install_out" >&2 + echo "Install failed. On Xiaomi / MIUI, confirm the on-device USB install dialog if it appears." >&2 + exit 1 +fi +printf '%s\n' "$install_out" + +print_header "Set Device Owner" +if ! owner_out="$(adb_cmd shell dpm set-device-owner "$OWNER_COMPONENT" 2>&1)"; then + printf '%s\n' "$owner_out" >&2 + echo "set-device-owner failed. The device must be factory reset and have zero accounts. See docs/device-owner/xiaomi-miui.md for vendor-specific workarounds." >&2 + exit 1 +fi +printf '%s\n' "$owner_out" + +print_header "Owner Result" +adb_cmd shell dpm list-owners diff --git a/tools/device-owner/20-repair-single-owner-runtime.sh b/tools/device-owner/20-repair-single-owner-runtime.sh new file mode 100755 index 0000000..e0397fb --- /dev/null +++ b/tools/device-owner/20-repair-single-owner-runtime.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$DIR/common.sh" + +print_header "Wait For Boot" +wait_for_boot + +print_header "Accessibility" +adb_cmd shell settings put secure enabled_accessibility_services "$A11Y_SERVICE" +adb_cmd shell settings put secure accessibility_enabled 1 + +print_header "AppOps" +adb_cmd shell appops set "$PKG" SYSTEM_ALERT_WINDOW allow || true +adb_cmd shell appops set "$PKG" MANAGE_EXTERNAL_STORAGE allow || true +adb_cmd shell cmd appops set "$PKG" GET_USAGE_STATS allow || true + +print_header "Runtime Permissions" +grant_if_possible android.permission.RECORD_AUDIO +grant_if_possible android.permission.CAMERA +grant_if_possible android.permission.ACCESS_FINE_LOCATION +grant_if_possible android.permission.ACCESS_COARSE_LOCATION +grant_if_possible android.permission.READ_PHONE_STATE +grant_if_possible android.permission.READ_SMS +grant_if_possible android.permission.READ_EXTERNAL_STORAGE +grant_if_possible android.permission.WRITE_EXTERNAL_STORAGE +grant_if_possible android.permission.GET_ACCOUNTS + +print_header "Launch App" +adb_cmd shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true diff --git a/tools/device-owner/30-verify-single-owner.sh b/tools/device-owner/30-verify-single-owner.sh new file mode 100755 index 0000000..ccd8fbc --- /dev/null +++ b/tools/device-owner/30-verify-single-owner.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$DIR/common.sh" + +print_header "Wait For Boot" +wait_for_boot + +print_header "Owner" +adb_cmd shell dpm list-owners + +print_header "Accessibility" +adb_cmd shell dumpsys accessibility | sed -n '1,24p' + +print_header "Package" +adb_cmd shell dumpsys package "$PKG" | grep -E 'versionName|versionCode|firstInstallTime|lastUpdateTime' + +print_header "Agent Smoke Test" +adb_cmd logcat -c +adb_cmd shell am broadcast \ + -a com.andforce.andclaw.action.START_AGENT \ + -n "$DEBUG_RECEIVER" \ + --es prompt '截一张当前屏幕并结束任务' +sleep 20 + +LOG_OUT="$(adb_cmd logcat -d -s AgentDebugReceiver:D AgentController:D)" +printf '%s\n' "$LOG_OUT" + +if ! printf '%s\n' "$LOG_OUT" | grep -q '\[system\]: Finished\.'; then + echo "Agent smoke test did not finish cleanly" >&2 + exit 1 +fi + +print_header "Latest Screenshot" +adb_cmd shell ls -lt /sdcard/Pictures/Andclaw | sed -n '1,4p' diff --git a/tools/device-owner/35-reboot-and-reverify.sh b/tools/device-owner/35-reboot-and-reverify.sh new file mode 100755 index 0000000..8063f77 --- /dev/null +++ b/tools/device-owner/35-reboot-and-reverify.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$DIR/common.sh" + +print_header "Reboot Device" +adb_cmd reboot + +print_header "Wait For Reboot" +wait_for_boot + +"$DIR/00-diagnose-reset-state.sh" +"$DIR/30-verify-single-owner.sh" diff --git a/tools/device-owner/40-bootstrap-after-reset.sh b/tools/device-owner/40-bootstrap-after-reset.sh new file mode 100755 index 0000000..6d185d2 --- /dev/null +++ b/tools/device-owner/40-bootstrap-after-reset.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$DIR/common.sh" + +APK="$(resolve_apk "${1:-}")" + +"$DIR/00-diagnose-reset-state.sh" || true +if ! "$DIR/10-provision-single-owner.sh" "$APK"; then + "$DIR/00-diagnose-reset-state.sh" || true + exit 1 +fi +"$DIR/20-repair-single-owner-runtime.sh" +"$DIR/30-verify-single-owner.sh" diff --git a/tools/device-owner/50-capture-agent-baseline.sh b/tools/device-owner/50-capture-agent-baseline.sh new file mode 100755 index 0000000..76a232a --- /dev/null +++ b/tools/device-owner/50-capture-agent-baseline.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$DIR/common.sh" + +STAMP="$(date '+%Y%m%dT%H%M%S')" +OUT_DIR="$RUNS_DIR/baseline-$STAMP" +PROMPT="${1:-开始录屏;打开浏览器并停留在首页;返回桌面;打开设置查看电池页面并截图;返回桌面;停止录屏并结束任务}" + +mkdir -p "$OUT_DIR" + +print_header "Create Output Directory" +echo "$OUT_DIR" + +print_header "Baseline State" +"$DIR/00-diagnose-reset-state.sh" | tee "$OUT_DIR/device-state.txt" + +print_header "Record Existing Artifacts" +BEFORE_SCREENSHOT="$(adb_cmd shell ls -t /sdcard/Pictures/Andclaw 2>/dev/null | head -n 1 | tr -d '\r' || true)" +BEFORE_VIDEO="$(adb_cmd shell ls -t /sdcard/Movies/Andclaw 2>/dev/null | head -n 1 | tr -d '\r' || true)" +printf 'before_screenshot=%s\nbefore_video=%s\n' "$BEFORE_SCREENSHOT" "$BEFORE_VIDEO" | tee "$OUT_DIR/before-artifacts.txt" + +print_header "Start Agent Benchmark" +adb_cmd logcat -c +adb_cmd shell am broadcast \ + -a com.andforce.andclaw.action.START_AGENT \ + -n "$DEBUG_RECEIVER" \ + --es prompt "$PROMPT" >/dev/null + +DONE=0 +COUNT=0 +while [ "$COUNT" -lt 48 ]; do + sleep 5 + COUNT=$((COUNT + 1)) + LOG_OUT="$(adb_cmd logcat -d -s AgentDebugReceiver:D AgentController:D AiAccessibility:D)" + printf '%s\n' "$LOG_OUT" > "$OUT_DIR/agent-logcat.txt" + if printf '%s\n' "$LOG_OUT" | grep -q '\[system\]: Finished\.'; then + DONE=1 + break + fi +done + +if [ "$DONE" -ne 1 ]; then + echo "Benchmark agent did not finish within timeout" >&2 + exit 1 +fi + +print_header "Collect New Artifacts" +AFTER_SCREENSHOT="$(adb_cmd shell ls -t /sdcard/Pictures/Andclaw 2>/dev/null | head -n 1 | tr -d '\r' || true)" +AFTER_VIDEO="$(adb_cmd shell ls -t /sdcard/Movies/Andclaw 2>/dev/null | head -n 1 | tr -d '\r' || true)" +printf 'after_screenshot=%s\nafter_video=%s\n' "$AFTER_SCREENSHOT" "$AFTER_VIDEO" | tee "$OUT_DIR/after-artifacts.txt" + +if [ -n "$AFTER_SCREENSHOT" ] && [ "$AFTER_SCREENSHOT" != "$BEFORE_SCREENSHOT" ]; then + adb_cmd pull "/sdcard/Pictures/Andclaw/$AFTER_SCREENSHOT" "$OUT_DIR/$AFTER_SCREENSHOT" >/dev/null +fi + +if [ -n "$AFTER_VIDEO" ] && [ "$AFTER_VIDEO" != "$BEFORE_VIDEO" ]; then + adb_cmd pull "/sdcard/Movies/Andclaw/$AFTER_VIDEO" "$OUT_DIR/$AFTER_VIDEO" >/dev/null + if command -v ffprobe >/dev/null 2>&1; then + ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 "$OUT_DIR/$AFTER_VIDEO" \ + > "$OUT_DIR/video-metadata.txt" || true + fi +fi + +print_header "Capture Final Activity" +adb_cmd shell dumpsys activity activities | grep -E 'mResumedActivity|topResumedActivity' \ + > "$OUT_DIR/final-activity.txt" || true + +print_header "Summary" +{ + echo "out_dir=$OUT_DIR" + echo "prompt=$PROMPT" + echo "agent_finished=yes" + echo "screenshot=$AFTER_SCREENSHOT" + echo "video=$AFTER_VIDEO" +} | tee "$OUT_DIR/summary.txt" diff --git a/tools/device-owner/common.sh b/tools/device-owner/common.sh new file mode 100755 index 0000000..78e51e4 --- /dev/null +++ b/tools/device-owner/common.sh @@ -0,0 +1,73 @@ +#!/bin/sh + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +ROOT_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +PKG="${ANDCLAW_PACKAGE:-com.andforce.andclaw}" +OWNER_COMPONENT="${ANDCLAW_OWNER_COMPONENT:-$PKG/.DeviceAdminReceiver}" +A11Y_SERVICE="${ANDCLAW_ACCESSIBILITY_SERVICE:-$PKG/com.andforce.andclaw.AgentAccessibilityService}" +DEBUG_RECEIVER="${ANDCLAW_DEBUG_RECEIVER:-$PKG/com.andforce.andclaw.AgentDebugReceiver}" + +ARTIFACTS_DIR="${ANDCLAW_ARTIFACTS_DIR:-$ROOT_DIR/out/device-owner}" +RUNS_DIR="${ANDCLAW_RUNS_DIR:-$ARTIFACTS_DIR}" +APK_DEFAULT="${ANDCLAW_DEVICE_OWNER_APK:-$ARTIFACTS_DIR/andclaw-single-owner-signed.apk}" + +adb_cmd() { + if [ -n "${ANDROID_SERIAL:-}" ]; then + adb -s "$ANDROID_SERIAL" "$@" + else + adb "$@" + fi +} + +print_header() { + printf '\n== %s ==\n' "$1" +} + +ensure_artifacts_dir() { + mkdir -p "$ARTIFACTS_DIR" +} + +wait_for_device() { + adb_cmd wait-for-device +} + +wait_for_boot() { + wait_for_device + while :; do + boot="$(adb_cmd shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + if [ "$boot" = "1" ]; then + break + fi + sleep 2 + done + sleep 3 +} + +grant_if_possible() { + perm="$1" + adb_cmd shell pm grant "$PKG" "$perm" >/dev/null 2>&1 || true +} + +resolve_apk() { + if [ -n "${1:-}" ]; then + printf '%s\n' "$1" + return 0 + fi + + if [ -f "$APK_DEFAULT" ]; then + printf '%s\n' "$APK_DEFAULT" + return 0 + fi + + if [ -d "$ARTIFACTS_DIR" ]; then + found_apk="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.apk' | sort | head -n 1)" + if [ -n "$found_apk" ]; then + printf '%s\n' "$found_apk" + return 0 + fi + fi + + echo "No APK found. Put a signed APK at $APK_DEFAULT, pass it as the first argument, or set ANDCLAW_DEVICE_OWNER_APK." >&2 + return 1 +}