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