diff --git a/README.md b/README.md index 639d51b..7f9a3c1 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ [![License](https://img.shields.io/badge/license-MIT-B8882A?labelColor=1C1914&style=flat-square)](#) [![Release](https://img.shields.io/badge/⬇︎%20下载%20APK-B8882A?labelColor=1C1914&style=flat-square)](https://yx.likeyou.qzz.io) [![Platform](https://img.shields.io/badge/Android%207%2B-0369A1?labelColor=1C1914&style=flat-square&logo=android&logoColor=white)](#) -[![Size](https://img.shields.io/badge/2.15%20MB-555?labelColor=1C1914&style=flat-square)](#) -[![Version](https://img.shields.io/badge/v1.8.1-B8882A?labelColor=1C1914&style=flat-square)](#) +[![Size](https://img.shields.io/badge/2.16%20MB-555?labelColor=1C1914&style=flat-square)](#) +[![Version](https://img.shields.io/badge/v1.9.0-B8882A?labelColor=1C1914&style=flat-square)](#) [![Stars](https://img.shields.io/github/stars/bjfwan/yinxing?color=B8882A&labelColor=1C1914&style=flat-square)](https://github.com/bjfwan/yinxing/stargazers) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c31d7f..5f45160 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,8 +43,8 @@ android { applicationId = "com.yinxing.launcher" minSdk = 24 targetSdk = 36 - versionCode = 15 - versionName = "1.8.1" + versionCode = 16 + versionName = "1.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt b/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt index f49fb0c..2793977 100644 --- a/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt +++ b/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt @@ -108,6 +108,7 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { private var lastMissingRootLogAt = 0L private val rootProvider = WeChatRootProvider(this) private val elementLocator = WeChatElementLocator(this) + private val taskEngine = WeChatVideoTaskEngine(maxAttemptsPerStep = MAX_STEP_RECOVERY_ATTEMPTS) private var wechatVersionTagged = false @@ -439,12 +440,22 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { currentClass: String?, session: VideoCallSession? = currentSession ): WeChatPage { - val page = detectWeChatPageLegacy(root, currentClass) + val semantic = snapshotOf(root)?.let(WeChatSemanticPageRecognizer::recognize) + val legacy = detectWeChatPageLegacy(root, currentClass) + val page = if (semantic != null && semantic.reliable) { + semantic.toWeChatPage().takeIf { it != WeChatPage.UNKNOWN } ?: legacy + } else { + legacy + } + session?.lastSemanticPage = semantic session?.lastDetectedPage = page return page } private fun detectWeChatPageLegacy(root: AccessibilityNodeInfo, currentClass: String?): WeChatPage { + if (elementLocator.isVideoCallSheetVisible(root)) { + return WeChatPage.VIDEO_SHEET + } if (currentClass == WeChatClassNames.SEARCH_UI || isSearchPage(root)) { return WeChatPage.SEARCH } @@ -460,6 +471,18 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { return WeChatPage.UNKNOWN } + private fun WeChatSemanticPageResult.toWeChatPage(): WeChatPage { + return when (page) { + WeChatSemanticPage.HOME -> WeChatPage.HOME + WeChatSemanticPage.SEARCH -> WeChatPage.SEARCH + WeChatSemanticPage.CONTACT_DETAIL -> WeChatPage.CONTACT_DETAIL + WeChatSemanticPage.CHAT -> WeChatPage.CHAT + WeChatSemanticPage.VIDEO_SHEET -> WeChatPage.VIDEO_SHEET + WeChatSemanticPage.NO_RESULT -> WeChatPage.SEARCH + WeChatSemanticPage.UNKNOWN -> WeChatPage.UNKNOWN + } + } + private fun isSearchPage(root: AccessibilityNodeInfo): Boolean { @@ -627,6 +650,7 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { ) { recordStepHistory(session, nextStep) session.step = nextStep + syncTaskState(session, nextStep) session.stepStartedAt = System.currentTimeMillis() resetForStepEntry(session, nextStep) session.moreButtonClickedAt = 0L @@ -760,6 +784,10 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { "processCurrentWindow: step=${session.step} class=$currentClass rawClass=${root.className} lastUiClass=${rootProvider.lastObservedClassName}" } + if (applyTaskDecision(session, root, currentClass)) { + return + } + when (session.step) { Step.WAITING_HOME -> handleWaitingHome(session, root, currentClass) Step.WAITING_LAUNCHER_UI -> handleLauncherUI(session, root) @@ -774,6 +802,64 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { return WeChatUiSnapshot.fromNode(root) } + private fun applyTaskDecision( + session: VideoCallSession, + root: AccessibilityNodeInfo, + currentClass: String? + ): Boolean { + val snapshot = snapshotOf(root) + val semantic = snapshot?.let(WeChatSemanticPageRecognizer::recognize) + ?: WeChatSemanticPageResult(WeChatSemanticPage.UNKNOWN, 0, listOf("missing_snapshot")) + val contactScore = if (session.step == Step.WAITING_CONTACT_RESULT && snapshot != null) { + WeChatUiSnapshotAnalyzer.scoreContactSearchResult(snapshot, session.contactName) + } else { + null + } + val taskState = session.taskState.copy( + step = session.step.toTaskStep(), + contactName = session.contactName + ) + val decision = taskEngine.decide(taskState, semantic, contactScore) + session.taskState = decision.nextState + session.lastSemanticPage = semantic + session.lastTaskDecisionReason = decision.reason + DebugLog.d(TAG) { + "taskEngine: step=${taskState.step}, semantic=${semantic.page}/${semantic.confidence}, " + + "action=${decision.action}, reason=${decision.reason}, next=${decision.nextState.step}, score=${contactScore?.score}" + } + return when (decision.action) { + WeChatVideoTaskAction.FAIL -> { + failAndHide("未找到联系人: ${session.contactName}", root) + true + } + WeChatVideoTaskAction.RECOVER_HOME -> { + recoverToHome(session, root, currentClass, "taskEngine: ${decision.reason}") + true + } + else -> false + } + } + + private fun Step.toTaskStep(): WeChatVideoTaskStep { + return when (this) { + Step.WAITING_HOME, + Step.WAITING_LAUNCHER_UI -> WeChatVideoTaskStep.WAITING_HOME + Step.WAITING_SEARCH_FALLBACK -> WeChatVideoTaskStep.WAITING_SEARCH + Step.WAITING_CONTACT_RESULT -> WeChatVideoTaskStep.WAITING_CONTACT_RESULT + Step.WAITING_CONTACT_DETAIL -> WeChatVideoTaskStep.WAITING_CONTACT_DETAIL + Step.WAITING_VIDEO_OPTIONS -> WeChatVideoTaskStep.WAITING_VIDEO_OPTIONS + } + } + + private fun syncTaskState(session: VideoCallSession, step: Step) { + session.taskState = session.taskState.copy( + step = step.toTaskStep(), + contactName = session.contactName, + attempts = emptyMap(), + resolvedDisplayName = session.resolvedContactTitle ?: session.taskState.resolvedDisplayName + ) + } + private fun tryDismissTransientUi(session: VideoCallSession, root: AccessibilityNodeInfo?): Boolean { // 组合条件:只有当前 Step 期望的关键节点找不到时,才认为是弹窗干扰 // 避免把正常页面里的“我知道了”等按钮误当弹窗处理 @@ -935,6 +1021,15 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { reason = "WAITING_HOME: 当前在搜索页" ) } + WeChatPage.VIDEO_SHEET -> { + logStep(session, "detectPage", "VIDEO_SHEET", "视频弹窗意外出现,回首页") + recoverToHome( + session = session, + root = root, + currentClass = currentClass, + reason = "WAITING_HOME: 当前在视频弹窗" + ) + } WeChatPage.UNKNOWN -> { val observeAttempt = incrementActionAttempt(session, "home_observe") logStep(session, "detectPage", "UNKNOWN", "class=$currentClass observeAttempt=$observeAttempt childCount=${root.childCount}") @@ -1076,6 +1171,11 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { } return } + WeChatPage.VIDEO_SHEET -> { + logStep(session, "detectPage", "VIDEO_SHEET", "已出现视频选项,直接推进") + rerouteTo(session, Step.WAITING_VIDEO_OPTIONS, "正在选择视频通话") + return + } WeChatPage.HOME -> { logStep(session, "detectPage", "HOME", "搜索页已关闭回到首页,重新查找") session.searchTextApplied = false @@ -1123,6 +1223,11 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { } logStep(session, "detectPage", "CHAT", "聊天页,点+展开菜单发起视频通话") } + WeChatPage.VIDEO_SHEET -> { + logStep(session, "detectPage", "VIDEO_SHEET", "已出现视频选项,直接选择") + transitionTo(session, Step.WAITING_VIDEO_OPTIONS, "正在选择视频通话") + return + } WeChatPage.SEARCH -> { logStep(session, "detectPage", "SEARCH", "仍停留在搜索页,回到结果阶段") rerouteTo(session, Step.WAITING_CONTACT_RESULT, "正在打开联系人") @@ -1198,6 +1303,7 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { resolveAndRerouteTo(session, session.step, "WAITING_VIDEO_OPTIONS: launcherHome") return } + WeChatPage.VIDEO_SHEET -> Unit else -> Unit } @@ -1234,6 +1340,7 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { recordStepSuccess(oldStep, duration) recordStepHistory(session, nextStep) session.step = nextStep + syncTaskState(session, nextStep) session.stepStartedAt = System.currentTimeMillis() resetForStepEntry(session, nextStep) @@ -1260,6 +1367,11 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { session.stepDurations[session.step.name.lowercase()] = stepElapsed recordStepSuccess(session.step, stepElapsed) session.moreButtonClickedAt = 0L + session.taskState = session.taskState.copy( + step = WeChatVideoTaskStep.COMPLETED, + attempts = emptyMap() + ) + session.lastTaskDecisionReason = "completed" val totalElapsed = System.currentTimeMillis() - session.startedAt DebugLog.banner( @@ -1329,6 +1441,7 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { } } + @Suppress("DEPRECATION") private fun obtainSpeakerTargetRoot(): AccessibilityNodeInfo? { rootInActiveWindow?.let { return AccessibilityNodeInfo.obtain(it) } rootProvider.peekCachedRoot()?.let { return AccessibilityNodeInfo.obtain(it) } @@ -1433,13 +1546,23 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { System.currentTimeMillis() - session.stepStartedAt logStep(session, "FAILED", message) } + val sessionSnapshot = session?.failureSnapshot() + val rootSnapshot = snapshotOf(root) val diagnostics = WeChatFailureDiagnostics.build( message = message, - session = session?.failureSnapshot(), + session = sessionSnapshot, root = root, service = this ) WeChatFailureDiagnostics.logErrorLong(TAG, diagnostics) + serviceScope.launch(Dispatchers.IO) { + WeChatFailureDiagnostics.saveReplay( + context = this@SelectToSpeakService, + message = message, + session = sessionSnapshot, + root = rootSnapshot + ) + } LobsterClient.log("[微信自动] 失败诊断:\n$diagnostics") if (session != null) { reportTerminalMetrics(session, success = false) @@ -1584,7 +1707,10 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { stepDurations = stepDurations.toMap(), lastDetectedPage = lastDetectedPage?.name, lastProgressAt = lastProgressAt, - lastAnnouncedMessage = lastAnnouncedMessage + lastAnnouncedMessage = lastAnnouncedMessage, + lastSemanticPage = lastSemanticPage?.page?.name, + taskStep = taskState.step.name, + taskReason = lastTaskDecisionReason ) } @@ -1608,7 +1734,10 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { val stepDurations: MutableMap = mutableMapOf(), var lastProgressAt: Long = System.currentTimeMillis(), var dismissingUntil: Long = 0L, - var dismissAttempts: Int = 0 + var dismissAttempts: Int = 0, + var lastSemanticPage: WeChatSemanticPageResult? = null, + var taskState: WeChatVideoTaskState = WeChatVideoTaskState(contactName = contactName), + var lastTaskDecisionReason: String? = null ) @@ -1617,6 +1746,7 @@ class SelectToSpeakService : AccessibilityService(), WeChatRequestHost { SEARCH, CHAT, CONTACT_DETAIL, + VIDEO_SHEET, UNKNOWN } diff --git a/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatFailureDiagnostics.kt b/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatFailureDiagnostics.kt index 59965a9..2dc73ec 100644 --- a/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatFailureDiagnostics.kt +++ b/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatFailureDiagnostics.kt @@ -1,9 +1,13 @@ package com.google.android.accessibility.selecttospeak import android.accessibilityservice.AccessibilityService +import android.content.Context import android.view.accessibility.AccessibilityNodeInfo import com.yinxing.launcher.automation.wechat.util.AccessibilityUtil import com.yinxing.launcher.common.util.DebugLog +import java.io.File +import org.json.JSONArray +import org.json.JSONObject internal data class WeChatFailureSnapshot( val step: String, @@ -15,10 +19,22 @@ internal data class WeChatFailureSnapshot( val stepDurations: Map, val lastDetectedPage: String?, val lastProgressAt: Long, - val lastAnnouncedMessage: String? + val lastAnnouncedMessage: String?, + val lastSemanticPage: String? = null, + val taskStep: String? = null, + val taskReason: String? = null +) + +internal data class WeChatFailureReplay( + val message: String, + val createdAt: Long, + val session: WeChatFailureSnapshot?, + val root: WeChatUiSnapshot? ) internal object WeChatFailureDiagnostics { + private const val REPLAY_DIR = "wechat_failure_replay" + private const val LATEST_REPLAY = "latest.json" fun build( message: String, @@ -40,6 +56,9 @@ internal object WeChatFailureDiagnostics { append(", lastDetectedPage=").append(session.lastDetectedPage) append(", lastProgressAt=").append(session.lastProgressAt) append(", lastAnnouncedMessage=").append(session.lastAnnouncedMessage) + append(", lastSemanticPage=").append(session.lastSemanticPage) + append(", taskStep=").append(session.taskStep) + append(", taskReason=").append(session.taskReason) } append("\nroot=").append(AccessibilityUtil.summarizeNode(root)) append("\nwindows=").append(describeWindows(service)) @@ -47,6 +66,44 @@ internal object WeChatFailureDiagnostics { } } + fun saveReplay( + context: Context, + message: String, + session: WeChatFailureSnapshot?, + root: WeChatUiSnapshot?, + createdAt: Long = System.currentTimeMillis() + ): Boolean { + val dir = File(context.cacheDir, REPLAY_DIR) + if (!dir.exists() && !dir.mkdirs()) return false + val replay = WeChatFailureReplay(message, createdAt, session, root) + return runCatching { + File(dir, LATEST_REPLAY).writeText(encodeReplay(replay)) + true + }.getOrDefault(false) + } + + fun latestReplayFile(context: Context): File = File(File(context.cacheDir, REPLAY_DIR), LATEST_REPLAY) + + fun encodeReplay(replay: WeChatFailureReplay): String { + return JSONObject() + .put("version", 1) + .put("message", replay.message) + .put("createdAt", replay.createdAt) + .put("session", replay.session?.let(::sessionToJson)) + .put("root", replay.root?.let(::snapshotToJson)) + .toString() + } + + fun decodeReplay(value: String): WeChatFailureReplay { + val json = JSONObject(value) + return WeChatFailureReplay( + message = json.optString("message"), + createdAt = json.optLong("createdAt"), + session = json.optJSONObject("session")?.let(::sessionFromJson), + root = json.optJSONObject("root")?.let(::snapshotFromJson) + ) + } + fun describeWindows(service: AccessibilityService): String { val summaries = service.windows.orEmpty().mapIndexed { index, window -> val root = window.root @@ -76,4 +133,121 @@ internal object WeChatFailureDiagnostics { DebugLog.e(tag, "[$index] $chunk") } } + + private fun sessionToJson(session: WeChatFailureSnapshot): JSONObject { + return JSONObject() + .put("step", session.step) + .put("contactName", session.contactName) + .put("startedAt", session.startedAt) + .put("stepStartedAt", session.stepStartedAt) + .put("actionAttempts", mapToJson(session.actionAttempts)) + .put("stepHistory", listToJson(session.stepHistory)) + .put("stepDurations", mapToJson(session.stepDurations)) + .put("lastDetectedPage", session.lastDetectedPage) + .put("lastProgressAt", session.lastProgressAt) + .put("lastAnnouncedMessage", session.lastAnnouncedMessage) + .put("lastSemanticPage", session.lastSemanticPage) + .put("taskStep", session.taskStep) + .put("taskReason", session.taskReason) + } + + private fun sessionFromJson(json: JSONObject): WeChatFailureSnapshot { + return WeChatFailureSnapshot( + step = json.optString("step"), + contactName = json.optString("contactName"), + startedAt = json.optLong("startedAt"), + stepStartedAt = json.optLong("stepStartedAt"), + actionAttempts = jsonToIntMap(json.optJSONObject("actionAttempts")), + stepHistory = jsonToStringList(json.optJSONArray("stepHistory")), + stepDurations = jsonToLongMap(json.optJSONObject("stepDurations")), + lastDetectedPage = json.optString("lastDetectedPage").takeIf { it.isNotEmpty() }, + lastProgressAt = json.optLong("lastProgressAt"), + lastAnnouncedMessage = json.optString("lastAnnouncedMessage").takeIf { it.isNotEmpty() }, + lastSemanticPage = json.optString("lastSemanticPage").takeIf { it.isNotEmpty() }, + taskStep = json.optString("taskStep").takeIf { it.isNotEmpty() }, + taskReason = json.optString("taskReason").takeIf { it.isNotEmpty() } + ) + } + + private fun snapshotToJson(snapshot: WeChatUiSnapshot): JSONObject { + return JSONObject() + .put("text", snapshot.text) + .put("contentDescription", snapshot.contentDescription) + .put("viewIdResourceName", snapshot.viewIdResourceName) + .put("className", snapshot.className) + .put("clickable", snapshot.clickable) + .put("editable", snapshot.editable) + .put("bounds", snapshot.bounds?.let(::boundsToJson)) + .put("children", JSONArray().apply { + snapshot.children.forEach { put(snapshotToJson(it)) } + }) + } + + private fun snapshotFromJson(json: JSONObject): WeChatUiSnapshot { + val children = json.optJSONArray("children") + return WeChatUiSnapshot( + text = json.optString("text").takeIf { it.isNotEmpty() }, + contentDescription = json.optString("contentDescription").takeIf { it.isNotEmpty() }, + viewIdResourceName = json.optString("viewIdResourceName").takeIf { it.isNotEmpty() }, + className = json.optString("className").takeIf { it.isNotEmpty() }, + clickable = json.optBoolean("clickable"), + editable = json.optBoolean("editable"), + bounds = json.optJSONObject("bounds")?.let(::boundsFromJson), + children = buildList { + if (children != null) { + for (index in 0 until children.length()) { + add(snapshotFromJson(children.getJSONObject(index))) + } + } + } + ) + } + + private fun boundsToJson(bounds: WeChatUiBounds): JSONObject { + return JSONObject() + .put("left", bounds.left) + .put("top", bounds.top) + .put("right", bounds.right) + .put("bottom", bounds.bottom) + } + + private fun boundsFromJson(json: JSONObject): WeChatUiBounds { + return WeChatUiBounds( + left = json.optInt("left"), + top = json.optInt("top"), + right = json.optInt("right"), + bottom = json.optInt("bottom") + ) + } + + private fun mapToJson(map: Map): JSONObject { + return JSONObject().apply { + map.forEach { (key, value) -> put(key, value) } + } + } + + private fun listToJson(list: List): JSONArray { + return JSONArray().apply { + list.forEach(::put) + } + } + + private fun jsonToIntMap(json: JSONObject?): Map { + if (json == null) return emptyMap() + return json.keys().asSequence().associateWith { json.optInt(it) } + } + + private fun jsonToLongMap(json: JSONObject?): Map { + if (json == null) return emptyMap() + return json.keys().asSequence().associateWith { json.optLong(it) } + } + + private fun jsonToStringList(array: JSONArray?): List { + if (array == null) return emptyList() + return buildList { + for (index in 0 until array.length()) { + add(array.optString(index)) + } + } + } } diff --git a/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatUiSnapshot.kt b/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatUiSnapshot.kt index fe93358..d5f1a92 100644 --- a/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatUiSnapshot.kt +++ b/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatUiSnapshot.kt @@ -1,9 +1,20 @@ package com.google.android.accessibility.selecttospeak +import android.graphics.Rect import android.view.accessibility.AccessibilityNodeInfo import com.yinxing.launcher.automation.wechat.WeChatViewIds import com.yinxing.launcher.automation.wechat.util.AccessibilityUtil +internal data class WeChatUiBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int +) { + val centerY: Int + get() = (top + bottom) / 2 +} + internal data class WeChatUiSnapshot( val text: String? = null, val contentDescription: String? = null, @@ -11,6 +22,7 @@ internal data class WeChatUiSnapshot( val className: String? = null, val clickable: Boolean = false, val editable: Boolean = false, + val bounds: WeChatUiBounds? = null, val children: List = emptyList() ) { fun flatten(): Sequence = sequence { @@ -46,6 +58,8 @@ internal data class WeChatUiSnapshot( } } } + val bounds = Rect() + node.getBoundsInScreen(bounds) return WeChatUiSnapshot( text = node.text?.toString(), contentDescription = node.contentDescription?.toString(), @@ -53,6 +67,12 @@ internal data class WeChatUiSnapshot( className = node.className?.toString(), clickable = node.isClickable, editable = node.isEditable || node.className == "android.widget.EditText", + bounds = if (bounds.isEmpty) null else WeChatUiBounds( + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ), children = children ) } @@ -69,6 +89,21 @@ internal enum class WeChatDismissAction { CLOSE_DIALOG } +internal data class WeChatTargetScore( + val displayName: String?, + val score: Int, + val reasons: List +) { + val accepted: Boolean + get() = displayName != null && score >= 80 +} + +internal data class WeChatActionCandidate( + val label: String, + val score: Int, + val reasons: List +) + internal object WeChatUiSnapshotAnalyzer { private val noSearchResultTexts = listOf("无搜索结果", "没有找到", "无结果") private val closeDialogTexts = listOf("关闭", "我知道了", "稍后再说", "以后再说", "暂不") @@ -119,26 +154,79 @@ internal object WeChatUiSnapshotAnalyzer { } fun findContactSearchResultDisplayName(snapshot: WeChatUiSnapshot, contactName: String): String? { + val score = scoreContactSearchResult(snapshot, contactName) + return if (score.accepted) score.displayName else null + } + + fun scoreContactSearchResult(snapshot: WeChatUiSnapshot, contactName: String): WeChatTargetScore { val normalizedName = contactName.trim() if (normalizedName.isEmpty()) { - return null + return WeChatTargetScore(null, 0, listOf("blank_query")) } - val displayName = snapshot.flatten() - .firstNotNullOfOrNull { node -> - if (node.viewIdResourceName !in contactResultTitleIds) { - return@firstNotNullOfOrNull null - } - node.text?.trim()?.takeIf { it.isNotEmpty() } - ?: node.contentDescription?.trim()?.takeIf { it.isNotEmpty() } - } - ?: return null + val titleNode = snapshot.flatten().firstOrNull { node -> + node.viewIdResourceName in contactResultTitleIds && + readableText(node)?.isNotEmpty() == true + } ?: return WeChatTargetScore(null, 0, listOf("missing_title_id")) + val displayName = readableText(titleNode) ?: return WeChatTargetScore(null, 0, listOf("blank_title")) + var score = 40 + val reasons = mutableListOf("title_id") if (displayName == normalizedName) { - return displayName + score += 30 + reasons += "exact_title" + } + if (snapshot.clickable || snapshot.flatten().any { it.clickable }) { + score += 10 + reasons += "clickable" + } + if (displayName == normalizedName) { + if (hasNetworkResultMarker(snapshot)) { + score -= 50 + reasons += "network_marker" + } + return WeChatTargetScore(displayName, score.coerceAtLeast(0), reasons) } val texts = snapshot.flatten() .flatMap { node -> sequenceOf(node.text, node.contentDescription) } .mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } } - return if (texts.any { matchesContactSecondaryField(it, normalizedName) }) displayName else null + .toList() + if (texts.any { matchesContactSecondaryField(it, normalizedName) }) { + score += 35 + reasons += "secondary_field" + } + if (hasNetworkResultMarker(snapshot)) { + score -= 50 + reasons += "network_marker" + } + return WeChatTargetScore(displayName, score.coerceAtLeast(0), reasons) + } + + fun rankActionCandidates(snapshot: WeChatUiSnapshot, labels: Collection): List { + val normalizedLabels = labels.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + if (normalizedLabels.isEmpty()) { + return emptyList() + } + return snapshot.flatten().mapNotNull { node -> + val label = readableText(node) ?: return@mapNotNull null + val matchedLabel = normalizedLabels.firstOrNull { target -> + label == target || label.contains(target) + } ?: return@mapNotNull null + val exact = label == matchedLabel + var score = if (exact) 60 else 35 + val reasons = mutableListOf(if (exact) "exact_text" else "contains_text") + if (node.clickable) { + score += 20 + reasons += "clickable" + } + if (node.viewIdResourceName != null) { + score += 10 + reasons += "view_id" + } + if (node.bounds != null) { + score += 5 + reasons += "bounds" + } + WeChatActionCandidate(matchedLabel, score, reasons) + }.sortedByDescending { it.score }.toList() } fun isVideoCallSheetVisible(snapshot: WeChatUiSnapshot): Boolean { @@ -172,6 +260,15 @@ internal object WeChatUiSnapshotAnalyzer { } } + private fun hasNetworkResultMarker(snapshot: WeChatUiSnapshot): Boolean { + return hasExactText(snapshot, "搜索网络结果") || hasContainingText(snapshot, "网络结果") + } + + private fun readableText(node: WeChatUiSnapshot): String? { + return node.text?.trim()?.takeIf { it.isNotEmpty() } + ?: node.contentDescription?.trim()?.takeIf { it.isNotEmpty() } + } + private fun hasConversationChrome(snapshot: WeChatUiSnapshot): Boolean { val ids = WeChatViewIds.MORE_BUTTON_BASE_IDS + WeChatViewIds.MESSAGE_TAB_ICON if (snapshot.flatten().any { node -> node.viewIdResourceName in ids }) { diff --git a/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatVideoTaskEngine.kt b/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatVideoTaskEngine.kt new file mode 100644 index 0000000..b686a44 --- /dev/null +++ b/app/src/main/java/com/google/android/accessibility/selecttospeak/WeChatVideoTaskEngine.kt @@ -0,0 +1,198 @@ +package com.google.android.accessibility.selecttospeak + +internal enum class WeChatSemanticPage { + HOME, + SEARCH, + CONTACT_DETAIL, + CHAT, + VIDEO_SHEET, + NO_RESULT, + UNKNOWN +} + +internal data class WeChatSemanticPageResult( + val page: WeChatSemanticPage, + val confidence: Int, + val evidence: List +) { + val reliable: Boolean + get() = confidence >= 70 +} + +internal object WeChatSemanticPageRecognizer { + fun recognize(snapshot: WeChatUiSnapshot?): WeChatSemanticPageResult { + if (snapshot == null) { + return WeChatSemanticPageResult(WeChatSemanticPage.UNKNOWN, 0, listOf("missing_snapshot")) + } + val results = listOf( + scoreVideoSheet(snapshot), + scoreNoResult(snapshot), + scoreContactDetail(snapshot), + scoreSearch(snapshot), + scoreChat(snapshot), + scoreHome(snapshot) + ) + return results.maxBy { it.confidence }.takeIf { it.confidence > 0 } + ?: WeChatSemanticPageResult(WeChatSemanticPage.UNKNOWN, 10, listOf("no_known_evidence")) + } + + private fun scoreHome(snapshot: WeChatUiSnapshot): WeChatSemanticPageResult { + if (!WeChatUiSnapshotAnalyzer.isLauncherReady(snapshot)) { + return WeChatSemanticPageResult(WeChatSemanticPage.HOME, 0, emptyList()) + } + return WeChatSemanticPageResult(WeChatSemanticPage.HOME, 90, listOf("main_tabs")) + } + + private fun scoreSearch(snapshot: WeChatUiSnapshot): WeChatSemanticPageResult { + if (!WeChatUiSnapshotAnalyzer.isSearchPage(snapshot)) { + return WeChatSemanticPageResult(WeChatSemanticPage.SEARCH, 0, emptyList()) + } + return WeChatSemanticPageResult(WeChatSemanticPage.SEARCH, 86, listOf("editable", "search_chrome")) + } + + private fun scoreContactDetail(snapshot: WeChatUiSnapshot): WeChatSemanticPageResult { + if (!WeChatUiSnapshotAnalyzer.isContactInfoPage(snapshot)) { + return WeChatSemanticPageResult(WeChatSemanticPage.CONTACT_DETAIL, 0, emptyList()) + } + return WeChatSemanticPageResult(WeChatSemanticPage.CONTACT_DETAIL, 88, listOf("contact_actions")) + } + + private fun scoreChat(snapshot: WeChatUiSnapshot): WeChatSemanticPageResult { + if (!WeChatUiSnapshotAnalyzer.isChatPageLike(snapshot)) { + return WeChatSemanticPageResult(WeChatSemanticPage.CHAT, 0, emptyList()) + } + return WeChatSemanticPageResult(WeChatSemanticPage.CHAT, 82, listOf("editable", "conversation_chrome")) + } + + private fun scoreVideoSheet(snapshot: WeChatUiSnapshot): WeChatSemanticPageResult { + if (!WeChatUiSnapshotAnalyzer.isVideoCallSheetVisible(snapshot)) { + return WeChatSemanticPageResult(WeChatSemanticPage.VIDEO_SHEET, 0, emptyList()) + } + return WeChatSemanticPageResult(WeChatSemanticPage.VIDEO_SHEET, 95, listOf("video_option", "voice_option", "cancel")) + } + + private fun scoreNoResult(snapshot: WeChatUiSnapshot): WeChatSemanticPageResult { + if (!WeChatUiSnapshotAnalyzer.hasNoSearchResult(snapshot)) { + return WeChatSemanticPageResult(WeChatSemanticPage.NO_RESULT, 0, emptyList()) + } + return WeChatSemanticPageResult(WeChatSemanticPage.NO_RESULT, 92, listOf("no_search_result")) + } +} + +internal enum class WeChatVideoTaskStep { + WAITING_HOME, + WAITING_SEARCH, + WAITING_CONTACT_RESULT, + WAITING_CONTACT_DETAIL, + WAITING_VIDEO_OPTIONS, + COMPLETED, + FAILED +} + +internal enum class WeChatVideoTaskAction { + OPEN_SEARCH, + TYPE_CONTACT, + OPEN_CONTACT, + OPEN_VIDEO_ENTRY, + CONFIRM_VIDEO_CALL, + WAIT, + RECOVER_HOME, + COMPLETE, + FAIL +} + +internal data class WeChatVideoTaskState( + val step: WeChatVideoTaskStep = WeChatVideoTaskStep.WAITING_HOME, + val contactName: String, + val attempts: Map = emptyMap(), + val resolvedDisplayName: String? = null +) + +internal data class WeChatVideoTaskDecision( + val nextState: WeChatVideoTaskState, + val action: WeChatVideoTaskAction, + val reason: String +) + +internal class WeChatVideoTaskEngine( + private val maxAttemptsPerStep: Int = 3 +) { + fun decide( + state: WeChatVideoTaskState, + page: WeChatSemanticPageResult, + contactScore: WeChatTargetScore? = null + ): WeChatVideoTaskDecision { + if (!page.reliable && state.step != WeChatVideoTaskStep.COMPLETED) { + return retryOrRecover(state, "low_confidence_${page.page.name.lowercase()}") + } + return when (state.step) { + WeChatVideoTaskStep.WAITING_HOME -> when (page.page) { + WeChatSemanticPage.HOME -> next(state, WeChatVideoTaskStep.WAITING_SEARCH, WeChatVideoTaskAction.OPEN_SEARCH, "home_ready") + else -> retryOrRecover(state, "need_home") + } + WeChatVideoTaskStep.WAITING_SEARCH -> when (page.page) { + WeChatSemanticPage.SEARCH -> next(state, WeChatVideoTaskStep.WAITING_CONTACT_RESULT, WeChatVideoTaskAction.TYPE_CONTACT, "search_ready") + else -> retryOrRecover(state, "need_search") + } + WeChatVideoTaskStep.WAITING_CONTACT_RESULT -> when { + page.page == WeChatSemanticPage.NO_RESULT -> fail(state, "no_contact_result") + contactScore?.accepted == true -> next( + state.copy(resolvedDisplayName = contactScore.displayName), + WeChatVideoTaskStep.WAITING_CONTACT_DETAIL, + WeChatVideoTaskAction.OPEN_CONTACT, + "contact_score_${contactScore.score}" + ) + else -> retryOrRecover(state, "weak_contact_result") + } + WeChatVideoTaskStep.WAITING_CONTACT_DETAIL -> when (page.page) { + WeChatSemanticPage.CONTACT_DETAIL, WeChatSemanticPage.CHAT -> next( + state, + WeChatVideoTaskStep.WAITING_VIDEO_OPTIONS, + WeChatVideoTaskAction.OPEN_VIDEO_ENTRY, + "contact_ready" + ) + else -> retryOrRecover(state, "need_contact_detail") + } + WeChatVideoTaskStep.WAITING_VIDEO_OPTIONS -> when (page.page) { + WeChatSemanticPage.VIDEO_SHEET -> next( + state, + WeChatVideoTaskStep.COMPLETED, + WeChatVideoTaskAction.CONFIRM_VIDEO_CALL, + "video_sheet_ready" + ) + else -> retryOrRecover(state, "need_video_sheet") + } + WeChatVideoTaskStep.COMPLETED -> WeChatVideoTaskDecision(state, WeChatVideoTaskAction.COMPLETE, "completed") + WeChatVideoTaskStep.FAILED -> WeChatVideoTaskDecision(state, WeChatVideoTaskAction.FAIL, "failed") + } + } + + private fun next( + state: WeChatVideoTaskState, + step: WeChatVideoTaskStep, + action: WeChatVideoTaskAction, + reason: String + ): WeChatVideoTaskDecision { + return WeChatVideoTaskDecision(state.copy(step = step), action, reason) + } + + private fun retryOrRecover(state: WeChatVideoTaskState, reason: String): WeChatVideoTaskDecision { + val attempts = state.attempts[state.step].orZero() + 1 + val nextState = state.copy(attempts = state.attempts + (state.step to attempts)) + return if (attempts >= maxAttemptsPerStep) { + WeChatVideoTaskDecision( + nextState.copy(step = WeChatVideoTaskStep.WAITING_HOME), + WeChatVideoTaskAction.RECOVER_HOME, + reason + ) + } else { + WeChatVideoTaskDecision(nextState, WeChatVideoTaskAction.WAIT, reason) + } + } + + private fun fail(state: WeChatVideoTaskState, reason: String): WeChatVideoTaskDecision { + return WeChatVideoTaskDecision(state.copy(step = WeChatVideoTaskStep.FAILED), WeChatVideoTaskAction.FAIL, reason) + } + + private fun Int?.orZero(): Int = this ?: 0 +} diff --git a/app/src/main/java/com/yinxing/launcher/LauncherApplication.kt b/app/src/main/java/com/yinxing/launcher/LauncherApplication.kt index 821637f..f18b9ed 100644 --- a/app/src/main/java/com/yinxing/launcher/LauncherApplication.kt +++ b/app/src/main/java/com/yinxing/launcher/LauncherApplication.kt @@ -2,10 +2,8 @@ package com.yinxing.launcher import android.app.Application import android.content.ComponentCallbacks2 -import android.content.IntentFilter import android.os.Handler import android.os.Looper -import android.telephony.TelephonyManager import androidx.appcompat.app.AppCompatDelegate import com.yinxing.launcher.common.media.MediaThumbnailLoader import com.yinxing.launcher.common.perf.LauncherTraceNames @@ -14,7 +12,6 @@ import com.yinxing.launcher.common.perf.traceBegin import com.yinxing.launcher.data.home.LauncherAppRepository import com.yinxing.launcher.data.home.LauncherPreferences import com.yinxing.launcher.feature.incoming.IncomingCallForegroundService -import com.yinxing.launcher.feature.incoming.PhoneCallReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -22,14 +19,11 @@ import kotlinx.coroutines.launch class LauncherApplication : Application() { private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var phoneCallReceiverRegistered = false - private val phoneCallReceiver = PhoneCallReceiver() override fun onCreate() { super.onCreate() traceBegin(LauncherTraceNames.APP_INIT) applyDarkModePreference() - registerPhoneCallReceiver() Handler(Looper.getMainLooper()).postDelayed( { IncomingCallForegroundService.ensureNotificationChannels(this) @@ -44,23 +38,6 @@ class LauncherApplication : Application() { traceAndReport(this, LauncherTraceNames.APP_INIT) } - private fun registerPhoneCallReceiver() { - if (phoneCallReceiverRegistered) return - val filter = IntentFilter(TelephonyManager.ACTION_PHONE_STATE_CHANGED).apply { - priority = 999 - } - registerReceiver(phoneCallReceiver, filter) - phoneCallReceiverRegistered = true - } - - override fun onTerminate() { - if (phoneCallReceiverRegistered) { - unregisterReceiver(phoneCallReceiver) - phoneCallReceiverRegistered = false - } - super.onTerminate() - } - private fun applyDarkModePreference() { val mode = LauncherPreferences.getInstance(this).getDarkMode() AppCompatDelegate.setDefaultNightMode( diff --git a/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingAutoAnswerDecision.kt b/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingAutoAnswerDecision.kt index 6ee8b2b..61aa44b 100644 --- a/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingAutoAnswerDecision.kt +++ b/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingAutoAnswerDecision.kt @@ -14,16 +14,17 @@ object IncomingAutoAnswerDecisionMaker { contacts: List, incomingNumber: String, delaySeconds: Int, - globalAutoAnswer: Boolean = false + globalAutoAnswer: Boolean = true ): IncomingAutoAnswerDecision { val matchedContact = IncomingNumberMatcher.findBestMatch( contacts = contacts, incomingNumber = incomingNumber ) + val contactAllowsAutoAnswer = matchedContact?.autoAnswer == true return IncomingAutoAnswerDecision( callerLabel = matchedContact?.name ?: incomingNumber.trim().takeIf { it.isNotEmpty() }, matchedContact = matchedContact, - autoAnswer = globalAutoAnswer || matchedContact?.autoAnswer == true, + autoAnswer = globalAutoAnswer && contactAllowsAutoAnswer, delaySeconds = delaySeconds.coerceIn(1, 30) ) } diff --git a/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingCallCompatibilityDecision.kt b/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingCallCompatibilityDecision.kt new file mode 100644 index 0000000..5f31594 --- /dev/null +++ b/app/src/main/java/com/yinxing/launcher/feature/incoming/IncomingCallCompatibilityDecision.kt @@ -0,0 +1,106 @@ +package com.yinxing.launcher.feature.incoming + +import android.os.Build + +enum class IncomingCallAcceptStrategy { + TelecomManager, + HeadsetHook, + ManualOnly +} + +enum class IncomingCallCompatibilityBlocker { + ContactWhitelist, + GlobalAutoAnswer, + ContactAutoAnswer, + PhonePermission, + NotificationPermission, + DefaultLauncher, + BatteryOptimization, + AutoStart, + BackgroundStart, + UnsupportedPlatform +} + +data class IncomingCallCompatibilityInput( + val sdkInt: Int, + val knownContact: Boolean, + val globalAutoAnswerEnabled: Boolean, + val contactAutoAnswerEnabled: Boolean, + val hasPhonePermission: Boolean, + val hasNotificationPermission: Boolean, + val isDefaultLauncher: Boolean, + val ignoresBatteryOptimizations: Boolean, + val autoStartConfirmed: Boolean, + val backgroundStartConfirmed: Boolean +) + +data class IncomingCallCompatibilityDecision( + val canAutoAnswer: Boolean, + val strategy: IncomingCallAcceptStrategy, + val blockers: List, + val confidence: Int +) { + val isReliable: Boolean + get() = canAutoAnswer && confidence >= 70 +} + +object IncomingCallCompatibilityDecisionEngine { + fun decide(input: IncomingCallCompatibilityInput): IncomingCallCompatibilityDecision { + val blockers = buildList { + if (!input.knownContact) add(IncomingCallCompatibilityBlocker.ContactWhitelist) + if (!input.globalAutoAnswerEnabled) add(IncomingCallCompatibilityBlocker.GlobalAutoAnswer) + if (!input.contactAutoAnswerEnabled) add(IncomingCallCompatibilityBlocker.ContactAutoAnswer) + if (!input.hasPhonePermission) add(IncomingCallCompatibilityBlocker.PhonePermission) + if (input.sdkInt >= Build.VERSION_CODES.TIRAMISU && !input.hasNotificationPermission) { + add(IncomingCallCompatibilityBlocker.NotificationPermission) + } + if (!input.isDefaultLauncher) add(IncomingCallCompatibilityBlocker.DefaultLauncher) + if (!input.ignoresBatteryOptimizations) add(IncomingCallCompatibilityBlocker.BatteryOptimization) + if (!input.autoStartConfirmed) add(IncomingCallCompatibilityBlocker.AutoStart) + if (!input.backgroundStartConfirmed) add(IncomingCallCompatibilityBlocker.BackgroundStart) + if (input.sdkInt < Build.VERSION_CODES.N) add(IncomingCallCompatibilityBlocker.UnsupportedPlatform) + } + val strategy = when { + input.sdkInt >= Build.VERSION_CODES.O -> IncomingCallAcceptStrategy.TelecomManager + input.sdkInt >= Build.VERSION_CODES.N -> IncomingCallAcceptStrategy.HeadsetHook + else -> IncomingCallAcceptStrategy.ManualOnly + } + val confidence = confidenceFor(strategy, blockers) + return IncomingCallCompatibilityDecision( + canAutoAnswer = blockers.isEmpty() && strategy != IncomingCallAcceptStrategy.ManualOnly, + strategy = if (blockers.contains(IncomingCallCompatibilityBlocker.UnsupportedPlatform)) { + IncomingCallAcceptStrategy.ManualOnly + } else { + strategy + }, + blockers = blockers, + confidence = confidence + ) + } + + private fun confidenceFor( + strategy: IncomingCallAcceptStrategy, + blockers: List + ): Int { + val base = when (strategy) { + IncomingCallAcceptStrategy.TelecomManager -> 94 + IncomingCallAcceptStrategy.HeadsetHook -> 66 + IncomingCallAcceptStrategy.ManualOnly -> 0 + } + val penalty = blockers.sumOf { blocker -> + when (blocker) { + IncomingCallCompatibilityBlocker.ContactWhitelist, + IncomingCallCompatibilityBlocker.GlobalAutoAnswer, + IncomingCallCompatibilityBlocker.ContactAutoAnswer, + IncomingCallCompatibilityBlocker.PhonePermission, + IncomingCallCompatibilityBlocker.UnsupportedPlatform -> 40 + IncomingCallCompatibilityBlocker.NotificationPermission -> 18 + IncomingCallCompatibilityBlocker.DefaultLauncher -> 14 + IncomingCallCompatibilityBlocker.BatteryOptimization, + IncomingCallCompatibilityBlocker.AutoStart, + IncomingCallCompatibilityBlocker.BackgroundStart -> 10 + } + } + return (base - penalty).coerceIn(0, 100) + } +} diff --git a/app/src/main/java/com/yinxing/launcher/feature/incoming/PhoneCallReceiver.kt b/app/src/main/java/com/yinxing/launcher/feature/incoming/PhoneCallReceiver.kt index e37d12d..ec93170 100644 --- a/app/src/main/java/com/yinxing/launcher/feature/incoming/PhoneCallReceiver.kt +++ b/app/src/main/java/com/yinxing/launcher/feature/incoming/PhoneCallReceiver.kt @@ -3,9 +3,11 @@ package com.yinxing.launcher.feature.incoming import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.Build import android.telephony.TelephonyManager import com.yinxing.launcher.common.util.CallAudioStrategy import com.yinxing.launcher.common.util.DebugLog +import com.yinxing.launcher.common.util.PermissionUtil import com.yinxing.launcher.data.home.LauncherPreferences import com.yinxing.launcher.feature.phone.PhoneContactManager import java.util.concurrent.atomic.AtomicLong @@ -51,10 +53,6 @@ class PhoneCallReceiver : BroadcastReceiver() { DebugLog.e(TAG, "Failed to load phone contacts for incoming match", it) emptyList() } - val matchedContact = IncomingNumberMatcher.findBestMatch( - contacts = contacts, - incomingNumber = incomingNumber - ) val event = latestEvent.get() if (event == null || event.first != token || event.second != TelephonyManager.EXTRA_STATE_RINGING) { DebugLog.i(TAG) { @@ -62,24 +60,35 @@ class PhoneCallReceiver : BroadcastReceiver() { } return@launch } - callerLabel = matchedContact?.name ?: callerLabel - val globalAutoAnswer = LauncherPreferences.getInstance(appContext).isAutoAnswerEnabled() - val autoAnswer = globalAutoAnswer || matchedContact?.autoAnswer == true + val preferences = LauncherPreferences.getInstance(appContext) + val decision = IncomingAutoAnswerDecisionMaker.decide( + contacts = contacts, + incomingNumber = incomingNumber, + delaySeconds = preferences.getAutoAnswerDelaySeconds(), + globalAutoAnswer = preferences.isAutoAnswerEnabled() + ) + val compatibilityDecision = readCompatibilityDecision(appContext, preferences, decision) + val finalAutoAnswer = decision.autoAnswer && compatibilityDecision.canAutoAnswer + callerLabel = decision.callerLabel + DebugLog.i(TAG) { + "compatibility strategy=${compatibilityDecision.strategy}, autoAnswer=$finalAutoAnswer, " + + "confidence=${compatibilityDecision.confidence}, blockers=${compatibilityDecision.blockers}" + } runCatching { CallAudioStrategy.maximizeIncomingRingVolume(appContext) } .onFailure { DebugLog.w(TAG, "maximizeIncomingRingVolume failed", it) } IncomingCallDiagnostics.recordBroadcastReceived( context = appContext, callerLabel = callerLabel, incomingNumber = incomingNumber, - autoAnswer = autoAnswer + autoAnswer = finalAutoAnswer ) runCatching { IncomingCallForegroundService.start( context = appContext, callerName = callerLabel, - autoAnswer = autoAnswer, + autoAnswer = finalAutoAnswer, incomingNumber = incomingNumber, - knownContact = matchedContact != null + knownContact = decision.matchedContact != null ) }.onFailure { failure -> IncomingCallDiagnostics.recordServiceStartFailure( @@ -103,4 +112,27 @@ class PhoneCallReceiver : BroadcastReceiver() { } } } + + private fun readCompatibilityDecision( + context: Context, + preferences: LauncherPreferences, + decision: IncomingAutoAnswerDecision + ): IncomingCallCompatibilityDecision { + return IncomingCallCompatibilityDecisionEngine.decide( + IncomingCallCompatibilityInput( + sdkInt = Build.VERSION.SDK_INT, + knownContact = decision.matchedContact != null, + globalAutoAnswerEnabled = preferences.isAutoAnswerEnabled(), + contactAutoAnswerEnabled = decision.matchedContact?.autoAnswer == true, + hasPhonePermission = ready { PermissionUtil.hasPhonePermission(context) }, + hasNotificationPermission = ready { PermissionUtil.hasNotificationPermission(context) }, + isDefaultLauncher = ready { PermissionUtil.isDefaultLauncher(context) }, + ignoresBatteryOptimizations = ready { PermissionUtil.isIgnoringBatteryOptimizations(context) }, + autoStartConfirmed = preferences.isAutoStartConfirmed(), + backgroundStartConfirmed = preferences.isBackgroundStartConfirmed() + ) + ) + } + + private fun ready(block: () -> Boolean): Boolean = runCatching(block).getOrDefault(false) } diff --git a/app/src/main/java/com/yinxing/launcher/feature/phone/PhoneContactAdapter.kt b/app/src/main/java/com/yinxing/launcher/feature/phone/PhoneContactAdapter.kt index 817a7a0..42188d6 100644 --- a/app/src/main/java/com/yinxing/launcher/feature/phone/PhoneContactAdapter.kt +++ b/app/src/main/java/com/yinxing/launcher/feature/phone/PhoneContactAdapter.kt @@ -81,6 +81,10 @@ class PhoneContactAdapter( } else { holder.btnCall.isVisible = true holder.manageHint.isVisible = false + holder.btnCall.contentDescription = holder.itemView.context.getString( + R.string.contact_call_description, + contact.displayName + ) holder.btnCall.setOnClickListener { onCallClick(contact) } if (fullCardTapEnabled) { holder.itemView.setOnClickListener { onCallClick(contact) } diff --git a/app/src/main/java/com/yinxing/launcher/feature/videocall/VideoCallContactAdapter.kt b/app/src/main/java/com/yinxing/launcher/feature/videocall/VideoCallContactAdapter.kt index 19439fa..8bec8ac 100644 --- a/app/src/main/java/com/yinxing/launcher/feature/videocall/VideoCallContactAdapter.kt +++ b/app/src/main/java/com/yinxing/launcher/feature/videocall/VideoCallContactAdapter.kt @@ -1,5 +1,6 @@ package com.yinxing.launcher.feature.videocall +import android.content.res.ColorStateList import android.net.Uri import android.view.LayoutInflater import android.view.View @@ -14,6 +15,7 @@ import androidx.lifecycle.LifecycleCoroutineScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton import com.yinxing.launcher.R import com.yinxing.launcher.common.media.MediaThumbnailLoader import com.yinxing.launcher.data.contact.Contact @@ -59,7 +61,7 @@ class VideoCallContactAdapter( val photo: ImageView = view.findViewById(R.id.iv_video_contact_photo) val name: TextView = view.findViewById(R.id.tv_video_contact_name) val subtitle: TextView = view.findViewById(R.id.tv_video_contact_subtitle) - val btnVideoCall: View = view.findViewById(R.id.btn_video_call) + val btnVideoCall: MaterialButton = view.findViewById(R.id.btn_video_call) var photoJob: Job? = null } @@ -123,6 +125,18 @@ class VideoCallContactAdapter( bindSubtitle(holder, contact) val isWechat = contact.preferredAction == Contact.PreferredAction.WECHAT_VIDEO + holder.btnVideoCall.text = context.getString( + if (isWechat) R.string.contact_card_action_wechat_v2 else R.string.contact_card_action_phone_v2 + ) + holder.btnVideoCall.backgroundTintList = ColorStateList.valueOf( + ContextCompat.getColor( + context, + if (isWechat) R.color.launcher_video_action else R.color.launcher_phone_action + ) + ) + holder.btnVideoCall.setIconResource( + if (isWechat) android.R.drawable.ic_menu_camera else android.R.drawable.ic_menu_call + ) holder.btnVideoCall.contentDescription = context.getString( if (isWechat) R.string.video_contact_wechat_action_description diff --git a/app/src/main/res/layout/activity_phone_contact.xml b/app/src/main/res/layout/activity_phone_contact.xml index 53460b5..b2291ef 100644 --- a/app/src/main/res/layout/activity_phone_contact.xml +++ b/app/src/main/res/layout/activity_phone_contact.xml @@ -37,11 +37,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" - android:minHeight="52dp" + android:minHeight="60dp" android:paddingStart="20dp" - android:paddingTop="14dp" + android:paddingTop="16dp" android:paddingEnd="20dp" - android:paddingBottom="14dp" + android:paddingBottom="16dp" android:text="@string/action_back" /> @@ -83,11 +83,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" - android:minHeight="52dp" + android:minHeight="60dp" android:paddingStart="20dp" - android:paddingTop="14dp" + android:paddingTop="16dp" android:paddingEnd="20dp" - android:paddingBottom="14dp" + android:paddingBottom="16dp" android:text="@string/action_manage" /> @@ -140,11 +140,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" - android:minHeight="44dp" + android:minHeight="52dp" android:paddingStart="16dp" - android:paddingTop="10dp" + android:paddingTop="12dp" android:paddingEnd="16dp" - android:paddingBottom="10dp" + android:paddingBottom="12dp" android:text="@string/action_clear" android:textColor="@color/launcher_primary" /> diff --git a/app/src/main/res/layout/activity_video_call.xml b/app/src/main/res/layout/activity_video_call.xml index a6ddd5b..2134737 100644 --- a/app/src/main/res/layout/activity_video_call.xml +++ b/app/src/main/res/layout/activity_video_call.xml @@ -37,11 +37,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" - android:minHeight="52dp" + android:minHeight="60dp" android:paddingStart="20dp" - android:paddingTop="14dp" + android:paddingTop="16dp" android:paddingEnd="20dp" - android:paddingBottom="14dp" + android:paddingBottom="16dp" android:text="@string/action_back" /> @@ -83,11 +83,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" - android:minHeight="52dp" + android:minHeight="60dp" android:paddingStart="20dp" - android:paddingTop="14dp" + android:paddingTop="16dp" android:paddingEnd="20dp" - android:paddingBottom="14dp" + android:paddingBottom="16dp" android:text="@string/action_manage" /> @@ -140,11 +140,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" - android:minHeight="44dp" + android:minHeight="52dp" android:paddingStart="16dp" - android:paddingTop="10dp" + android:paddingTop="12dp" android:paddingEnd="16dp" - android:paddingBottom="10dp" + android:paddingBottom="12dp" android:text="@string/action_clear" android:textColor="@color/launcher_primary" /> diff --git a/app/src/main/res/layout/item_phone_contact.xml b/app/src/main/res/layout/item_phone_contact.xml index 268a014..f2016ca 100644 --- a/app/src/main/res/layout/item_phone_contact.xml +++ b/app/src/main/res/layout/item_phone_contact.xml @@ -16,7 +16,7 @@ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index b2b429d..aa6cb26 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -12,6 +12,10 @@ #1F2630 #10B981 #34D399 + #047857 + #0E2F24 + #2563EB + #10243F #F1F5F9 #94A3B8 #64748B diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d84373a..c93f297 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -12,6 +12,10 @@ #F1F5F9 #10B981 #059669 + #065F46 + #D1FAE5 + #0B4F9C + #DBEAFE #0F172A #475569 #94A3B8 diff --git a/app/src/main/res/values/typography.xml b/app/src/main/res/values/typography.xml index 26a7c13..77a6d05 100644 --- a/app/src/main/res/values/typography.xml +++ b/app/src/main/res/values/typography.xml @@ -11,7 +11,7 @@ 16sp @color/launcher_text_secondary false - 0.02 + 0