From f76289a4713f5fe95c2f9d25344c0b07c1360256 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Sat, 25 Apr 2026 01:25:12 +0900 Subject: [PATCH] Fix system audio capture incl. navigation; remove local-mute setting PrivilegedService (shell UID 2000) now registers an AudioPolicy loopback mix covering media + assistance/navigation/alarm/notification usages so Tesla mirroring receives all system audio, not just music. AudioRecord construction is wrapped with Binder.clearCallingIdentity() and a temporary Application.mBase swap so AttributionSource reports shell uid + com.android.shell, satisfying AudioFlinger's ValidatedAttributionSource checks. With real loopback capture, the prior "mute local audio" setting (which silenced stream volumes and therefore silenced REMOTE_SUBMIX too) is incompatible. Removed the toggle, preference key, MirrorForegroundService stream-volume manipulation, Shizuku `media volume --set 0` shell calls, and the muteLocalAudio input on AudioPolicy/AudioCaptureOrchestrator. Expanded scrcpy attribution in README (all locales) and NOTICE to specifically call out the SurfaceControl panel-power and fillAppInfo/FakeContext patterns this PR now extends to audio capture. Co-Authored-By: Claude Opus 4.7 --- NOTICE | 10 +- README.de.md | 7 +- README.es.md | 7 +- README.fr.md | 7 +- README.ja.md | 7 +- README.ko.md | 7 +- README.md | 7 +- README.zh-CN.md | 7 +- .../java/com/castla/mirror/MainActivity.kt | 1 - .../com/castla/mirror/policy/AudioPolicy.kt | 12 +- .../service/AudioCaptureOrchestrator.kt | 9 - .../mirror/service/MirrorForegroundService.kt | 88 +--- .../mirror/shizuku/PrivilegedService.kt | 382 +++++++++++++++--- .../com/castla/mirror/ui/SettingsScreen.kt | 43 +- .../com/castla/mirror/ui/SettingsState.kt | 4 - app/src/main/res/values-ko/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - .../castla/mirror/policy/AudioPolicyTest.kt | 35 +- .../service/AudioCaptureOrchestratorTest.kt | 34 +- .../castla/mirror/ui/StreamSettingsTest.kt | 2 - 20 files changed, 396 insertions(+), 277 deletions(-) diff --git a/NOTICE b/NOTICE index db1ebb4..570d6c3 100644 --- a/NOTICE +++ b/NOTICE @@ -39,8 +39,14 @@ Kotlin Standard Library / Kotlin Coroutines Acknowledgements Some privileged-mode techniques used by this project were inspired by the -approach taken in scrcpy (https://github.com/Genymobile/scrcpy, Apache-2.0). -No source code from scrcpy is included. +approach taken in scrcpy (https://github.com/Genymobile/scrcpy, Apache-2.0), +specifically: + - Physical display panel on/off via SurfaceControl for screen-off mirroring + - The fillAppInfo / FakeContext pattern for spoofing AttributionSource when + capturing system audio from a shell-UID service + +No source code from scrcpy is included; Castla reimplements these approaches +for its own architecture. ================================================================================ Test-only dependencies (not distributed in release APK) diff --git a/README.de.md b/README.de.md index 4c6fca6..8f514e2 100644 --- a/README.de.md +++ b/README.de.md @@ -94,7 +94,12 @@ Castla baut auf großartigen Open-Source-Projekten auf: - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — modernes Android-UI-Toolkit - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — asynchrone Streaming-Pipeline -Einige Techniken des privilegierten Modus wurden von [scrcpy](https://github.com/Genymobile/scrcpy) inspiriert. Es ist kein scrcpy-Quellcode enthalten. +Einige Techniken des privilegierten Modus wurden von [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0) inspiriert, insbesondere: + +- Physisches Ein-/Ausschalten des Displays über `SurfaceControl` für Bildschirm-aus-Mirroring +- Das `fillAppInfo` / `FakeContext`-Muster zum Spoofing der `AttributionSource` beim Erfassen von System-Audio aus einem Shell-UID-Dienst + +Es ist kein scrcpy-Quellcode enthalten — Castla implementiert diese Ansätze eigenständig. Siehe [NOTICE](NOTICE) für die vollständige Liste der Drittanbieter-Attributionen. diff --git a/README.es.md b/README.es.md index 8ac3fa9..8f1eb18 100644 --- a/README.es.md +++ b/README.es.md @@ -94,7 +94,12 @@ Castla se apoya en el trabajo de grandes proyectos de código abierto: - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — toolkit moderno de UI para Android - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — pipeline de streaming asíncrono -Algunas técnicas del modo privilegiado se inspiraron en [scrcpy](https://github.com/Genymobile/scrcpy). No se incluye código fuente de scrcpy. +Algunas técnicas del modo privilegiado se inspiraron en [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0), en concreto: + +- Encendido/apagado del panel físico de la pantalla mediante `SurfaceControl` para el mirroring con pantalla apagada +- El patrón `fillAppInfo` / `FakeContext` para falsificar `AttributionSource` al capturar audio del sistema desde un servicio con UID shell + +No se incluye código fuente de scrcpy — Castla reimplementa estos enfoques en su propia arquitectura. Consulta [NOTICE](NOTICE) para la lista completa de atribuciones de terceros. diff --git a/README.fr.md b/README.fr.md index 49f6485..f38a915 100644 --- a/README.fr.md +++ b/README.fr.md @@ -94,7 +94,12 @@ Castla s'appuie sur d'excellents projets open source : - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — boîte à outils UI Android moderne - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — pipeline de streaming asynchrone -Certaines techniques du mode privilégié s'inspirent de [scrcpy](https://github.com/Genymobile/scrcpy). Aucun code source de scrcpy n'est inclus. +Certaines techniques du mode privilégié s'inspirent de [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0), en particulier : + +- L'allumage/extinction physique du panneau d'affichage via `SurfaceControl` pour le mirroring écran éteint +- Le motif `fillAppInfo` / `FakeContext` pour usurper `AttributionSource` lors de la capture audio système depuis un service en UID shell + +Aucun code source de scrcpy n'est inclus — Castla réimplémente ces approches pour sa propre architecture. Voir [NOTICE](NOTICE) pour la liste complète des attributions tierces. diff --git a/README.ja.md b/README.ja.md index bd58a11..dc27ba8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -94,7 +94,12 @@ Castla は素晴らしいオープンソースプロジェクトの上に成り - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — モダンな Android UI ツールキット - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — 非同期ストリーミングパイプライン -一部の特権モード手法は [scrcpy](https://github.com/Genymobile/scrcpy) のアプローチから着想を得ています。scrcpy のソースコードは含まれていません。 +一部の特権モード手法は [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0) のアプローチから着想を得ています。具体的には次のとおりです: + +- 画面オフミラーリング時の `SurfaceControl` による物理ディスプレイパネルの電源制御 +- shell UID のサービスからシステム音声をキャプチャする際に `AttributionSource` を偽装するための `fillAppInfo` / `FakeContext` パターン + +scrcpy のソースコードは含まれておらず、Castla は自身のアーキテクチャに合わせてこれらの手法を再実装しています。 サードパーティの完全な表記については [NOTICE](NOTICE) を参照してください。 diff --git a/README.ko.md b/README.ko.md index e398299..609529c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -110,7 +110,12 @@ Castla는 훌륭한 오픈소스 프로젝트들의 도움으로 만들어졌습 - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — 현대적 Android UI 툴킷 - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — 비동기 스트리밍 파이프라인 -일부 특권 모드 기법은 [scrcpy](https://github.com/Genymobile/scrcpy)의 접근 방식에서 영감을 받았습니다. scrcpy의 소스 코드는 포함되어 있지 않습니다. +일부 특권 모드 기법은 [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0)의 접근 방식에서 영감을 받았으며, 구체적으로 다음과 같습니다: + +- 화면 꺼짐 미러링을 위한 `SurfaceControl` 기반 디스플레이 패널 전원 제어 +- shell UID 서비스에서 시스템 오디오를 캡처할 때 `AttributionSource`를 스푸핑하기 위한 `fillAppInfo` / `FakeContext` 패턴 + +scrcpy의 소스 코드는 포함되어 있지 않으며, Castla는 자체 아키텍처에 맞게 위 접근 방식을 재구현했습니다. 전체 서드파티 고지는 [NOTICE](NOTICE) 파일을 참조하세요. diff --git a/README.md b/README.md index f493b1a..5912f4d 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,12 @@ Castla stands on the shoulders of great open-source projects: - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — modern Android UI toolkit - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — async streaming pipeline -Some privileged-mode techniques were inspired by [scrcpy](https://github.com/Genymobile/scrcpy). No scrcpy source code is included. +Some privileged-mode techniques were inspired by [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0), specifically: + +- Physical display panel on/off via `SurfaceControl` for screen-off mirroring +- The `fillAppInfo` / `FakeContext` pattern for spoofing `AttributionSource` when capturing system audio from a shell-UID service + +No scrcpy source code is included — Castla reimplements these approaches for its own architecture. See [NOTICE](NOTICE) for the full list of third-party attributions. diff --git a/README.zh-CN.md b/README.zh-CN.md index 08a1f99..893c6e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -94,7 +94,12 @@ Castla 建立在出色的开源项目之上: - [AndroidX / Jetpack Compose](https://developer.android.com/jetpack) — 现代 Android UI 工具包 - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — 异步流式管道 -部分特权模式技术受 [scrcpy](https://github.com/Genymobile/scrcpy) 启发。本项目未包含 scrcpy 源代码。 +部分特权模式技术受 [scrcpy](https://github.com/Genymobile/scrcpy) (Apache-2.0) 启发,具体包括: + +- 通过 `SurfaceControl` 控制物理显示面板电源以实现息屏镜像 +- 使用 `fillAppInfo` / `FakeContext` 模式在 shell UID 服务中伪造 `AttributionSource` 以捕获系统音频 + +本项目未包含 scrcpy 源代码 — Castla 基于自身架构重新实现了上述方案。 完整的第三方归属列表请参见 [NOTICE](NOTICE)。 diff --git a/app/src/main/java/com/castla/mirror/MainActivity.kt b/app/src/main/java/com/castla/mirror/MainActivity.kt index 3ddb930..2b68f24 100644 --- a/app/src/main/java/com/castla/mirror/MainActivity.kt +++ b/app/src/main/java/com/castla/mirror/MainActivity.kt @@ -1211,7 +1211,6 @@ class MainActivity : AppCompatActivity() { if (streamSettings.isAutoResolution) 0 else streamSettings.maxResolution.maxHeight) putExtra(MirrorForegroundService.EXTRA_FPS, streamSettings.fps) // FPS_AUTO is already 0 putExtra(MirrorForegroundService.EXTRA_AUDIO, streamSettings.audioEnabled) - putExtra(MirrorForegroundService.EXTRA_MUTE_LOCAL_AUDIO, streamSettings.muteLocalAudio) putExtra(MirrorForegroundService.EXTRA_MIRRORING_MODE, streamSettings.mirroringMode.name) putExtra(MirrorForegroundService.EXTRA_TARGET_PACKAGE, streamSettings.targetAppPackage) } diff --git a/app/src/main/java/com/castla/mirror/policy/AudioPolicy.kt b/app/src/main/java/com/castla/mirror/policy/AudioPolicy.kt index a6dccfc..6fec769 100644 --- a/app/src/main/java/com/castla/mirror/policy/AudioPolicy.kt +++ b/app/src/main/java/com/castla/mirror/policy/AudioPolicy.kt @@ -8,7 +8,6 @@ data class AudioPolicyInput( val requestedCodec: String?, val currentCodec: String?, val browserConnected: Boolean, - val muteLocalAudio: Boolean, val captureActive: Boolean ) @@ -18,26 +17,20 @@ data class AudioPolicyInput( data class AudioPolicyDecision( val shouldCapture: Boolean, val codecMode: String?, - val shouldMuteLocal: Boolean, val restartRequired: Boolean ) /** * Pure policy object that determines audio capture behavior. * - * Separates two independent concerns: - * - "Capture audio for streaming" (controlled by audioEnabled + browserConnected) - * - "Mute local phone audio" (controlled by muteLocalAudio, only when capturing) - * - * This ensures music apps and video apps follow the same audio policy, - * and that the audio-off setting cannot be bypassed by codec requests. + * Capture is gated by audioEnabled + browserConnected. The audio-off setting + * cannot be bypassed by codec requests. */ object AudioPolicy { fun evaluate(input: AudioPolicyInput): AudioPolicyDecision { val shouldCapture = input.audioEnabled && input.browserConnected val codecMode = if (shouldCapture) (input.requestedCodec ?: input.currentCodec) else null - val shouldMuteLocal = shouldCapture && input.muteLocalAudio // null currentCodec means default start (opus). Treat "opus" request against null as same-path. val effectiveCurrent = input.currentCodec ?: "opus" val codecChanged = input.requestedCodec != null && input.requestedCodec != effectiveCurrent @@ -46,7 +39,6 @@ object AudioPolicy { return AudioPolicyDecision( shouldCapture = shouldCapture, codecMode = codecMode, - shouldMuteLocal = shouldMuteLocal, restartRequired = restartRequired ) } diff --git a/app/src/main/java/com/castla/mirror/service/AudioCaptureOrchestrator.kt b/app/src/main/java/com/castla/mirror/service/AudioCaptureOrchestrator.kt index 985142c..f220b00 100644 --- a/app/src/main/java/com/castla/mirror/service/AudioCaptureOrchestrator.kt +++ b/app/src/main/java/com/castla/mirror/service/AudioCaptureOrchestrator.kt @@ -18,7 +18,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) { interface Actions { fun startCapture(codec: String?) fun stopCapture() - fun applyMute(shouldMute: Boolean) fun grantAudioPermission() /** Schedule a callback to [onDeferredTimerExpired] after [delayMs]. Return a cancel handle. */ fun scheduleDeferredStart(delayMs: Long): Any? @@ -26,7 +25,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) { } var audioEnabled = false - var muteLocalAudio = false var browserConnected = false var currentCodec: String? = null var captureActive = false @@ -57,14 +55,12 @@ class AudioCaptureOrchestrator(private val actions: Actions) { if (captureActive) { actions.stopCapture() captureActive = false - actions.applyMute(false) } return EnsureResult.STOPPED } // Already capturing, no restart needed if (captureActive && !decision.restartRequired) { - actions.applyMute(decision.shouldMuteLocal) return EnsureResult.KEPT } @@ -115,7 +111,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) { } currentCodec = null audioSocketConnected = false - actions.applyMute(false) } private fun cancelDefer() { @@ -131,7 +126,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) { requestedCodec = codecOverride, currentCodec = currentCodec, browserConnected = browserConnected, - muteLocalAudio = muteLocalAudio, captureActive = captureActive )) @@ -142,7 +136,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) { if (!decision.shouldCapture) return EnsureResult.STOPPED if (captureActive && !decision.restartRequired) { - actions.applyMute(decision.shouldMuteLocal) return EnsureResult.KEPT } @@ -150,11 +143,9 @@ class AudioCaptureOrchestrator(private val actions: Actions) { if (captureActive) { actions.stopCapture() captureActive = false - actions.applyMute(false) } actions.grantAudioPermission() - actions.applyMute(decision.shouldMuteLocal) if (codecOverride != null) currentCodec = codecOverride diff --git a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt index 2b6e5a4..1acc4a3 100644 --- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt +++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt @@ -13,7 +13,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.hardware.display.DisplayManager -import android.media.AudioManager import android.net.wifi.WifiManager import android.os.Build import android.os.Binder @@ -78,7 +77,6 @@ class MirrorForegroundService : Service() { const val EXTRA_MAX_RESOLUTION = "max_resolution" const val EXTRA_FPS = "fps" const val EXTRA_AUDIO = "audio_enabled" - const val EXTRA_MUTE_LOCAL_AUDIO = "mute_local_audio" const val EXTRA_MIRRORING_MODE = "mirroring_mode" const val EXTRA_TARGET_PACKAGE = "target_package" @@ -172,7 +170,6 @@ class MirrorForegroundService : Service() { private var secondaryRequestedHeight: Int = 0 @Volatile private var currentCodecMode: String = "h264" private val pipelineMutex = Mutex() - private var savedMediaVolume: Int = -1 private val mainHandler = Handler(Looper.getMainLooper()) private var splitPresentation: SplitWebPresentation? = null private var singleVdSplit: Boolean = false @@ -214,7 +211,6 @@ class MirrorForegroundService : Service() { // Deferred pipeline state: heavy capture/encoding starts only when browser connects private var pendingAudioEnabled = false - private var pendingMuteLocalAudio = false private var deferredAudioStartJob: Job? = null private var thermalListener: PowerManager.OnThermalStatusChangedListener? = null @@ -619,11 +615,10 @@ class MirrorForegroundService : Service() { Log.i(TAG, "Mode: autoRes=$autoResolution autoFps=$autoFps initialMaxHeight=$currentMaxHeight initialFps=$settingsFps") val audioEnabled = intent.getBooleanExtra(EXTRA_AUDIO, false) - val muteLocalAudio = intent.getBooleanExtra(EXTRA_MUTE_LOCAL_AUDIO, false) mirroringMode = intent.getStringExtra(EXTRA_MIRRORING_MODE) ?: "FULL_SCREEN" targetPackage = intent.getStringExtra(EXTRA_TARGET_PACKAGE) ?: "" - startPipeline(resultCode, data, settingsFps, audioEnabled, muteLocalAudio) + startPipeline(resultCode, data, settingsFps, audioEnabled) return START_NOT_STICKY } @@ -944,8 +939,7 @@ class MirrorForegroundService : Service() { resultCode: Int, data: Intent, fps: Int, - audioEnabled: Boolean, - muteLocalAudio: Boolean = false + audioEnabled: Boolean ) { try { MirrorDiagnostics.onSessionStart() @@ -975,7 +969,6 @@ class MirrorForegroundService : Service() { currentHeight = height currentFps = fps pendingAudioEnabled = audioEnabled - pendingMuteLocalAudio = muteLocalAudio audioOrchestrator = AudioCaptureOrchestrator(object : AudioCaptureOrchestrator.Actions { override fun startCapture(codec: String?) { @@ -996,9 +989,6 @@ class MirrorForegroundService : Service() { try { audioCapture?.stop() } catch (_: Exception) {} audioCapture = null } - override fun applyMute(shouldMute: Boolean) { - if (shouldMute) muteMediaVolume() else restoreMediaVolume() - } override fun grantAudioPermission() { tryGrantAudioCapturePermission() } @@ -2515,7 +2505,6 @@ class MirrorForegroundService : Service() { private fun ensureAudioCaptureState(codecOverride: String? = null) { val orch = audioOrchestrator ?: return orch.audioEnabled = pendingAudioEnabled && AudioCapture.isSupported() - orch.muteLocalAudio = pendingMuteLocalAudio orch.browserConnected = browserConnected orch.ensure(codecOverride) } @@ -3073,79 +3062,6 @@ class MirrorForegroundService : Service() { } } - // Saved volumes for all streams we mute (stream type -> saved volume) - private val savedVolumes = mutableMapOf() - - private fun muteMediaVolume() { - try { - val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager - // Mute all streams that could carry navigation/media/notification audio - val streamsToMute = intArrayOf( - AudioManager.STREAM_MUSIC, // 3 - media, also navigation on most devices - AudioManager.STREAM_NOTIFICATION, // 5 - notifications - AudioManager.STREAM_SYSTEM, // 1 - system sounds - AudioManager.STREAM_DTMF, // 8 - DTMF tones - ) - for (stream in streamsToMute) { - try { - val current = am.getStreamVolume(stream) - if (current > 0) { - savedVolumes[stream] = current - am.setStreamVolume(stream, 0, 0) - } - } catch (_: Exception) {} - } - // Legacy compat - savedMediaVolume = savedVolumes[AudioManager.STREAM_MUSIC] ?: -1 - - // Use Shizuku to force-mute via shell — catches navigation guidance audio - // that bypasses normal stream volume controls - try { - val setup = shizukuSetup - val service = setup?.privilegedService - if (setup != null && service != null && setup.isAvailable() && setup.hasPermission()) { - for (stream in streamsToMute) { - service.execCommand("media volume --show --stream $stream --set 0") - } - Log.i(TAG, "Shizuku force-muted all audio streams") - } else { - Log.i(TAG, "Skipping Shizuku mute: privileged service not connected") - } - } catch (e: Exception) { - Log.w(TAG, "Shizuku stream mute failed", e) - } - } catch (e: Exception) { - Log.e(TAG, "muteMediaVolume failed", e) - } - } - - private fun restoreMediaVolume() { - try { - val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager - for ((stream, volume) in savedVolumes) { - try { - am.setStreamVolume(stream, volume, 0) - } catch (_: Exception) {} - } - - // Also restore via Shizuku - try { - val setup = shizukuSetup - val service = setup?.privilegedService - if (setup != null && service != null && setup.isAvailable() && setup.hasPermission()) { - for ((stream, volume) in savedVolumes) { - service.execCommand("media volume --show --stream $stream --set $volume") - } - } - } catch (_: Exception) {} - - savedVolumes.clear() - savedMediaVolume = -1 - } catch (e: Exception) { - Log.e(TAG, "restoreMediaVolume failed", e) - } - } - private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, "Mirror Service", NotificationManager.IMPORTANCE_LOW diff --git a/app/src/main/java/com/castla/mirror/shizuku/PrivilegedService.kt b/app/src/main/java/com/castla/mirror/shizuku/PrivilegedService.kt index acce960..2d4bdd8 100644 --- a/app/src/main/java/com/castla/mirror/shizuku/PrivilegedService.kt +++ b/app/src/main/java/com/castla/mirror/shizuku/PrivilegedService.kt @@ -5,9 +5,11 @@ import android.content.Intent import android.content.pm.PackageManager import android.hardware.display.DisplayManager import android.hardware.display.VirtualDisplay +import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder +import android.os.Binder import android.os.ParcelFileDescriptor import android.os.SystemClock import android.util.Log @@ -61,6 +63,91 @@ class PrivilegedService : IPrivilegedService.Stub() { init { tryInitInputManager() tryInitShellContext() + // Must run AFTER shell context init so ActivityThread state is prepared. This makes + // any subsequent AudioRecord/AudioTrack use packageName="com.android.shell" + // matching our shell uid 2000 — required for AudioFlinger's attribution validator. + fillShellAppInfo() + } + + /** + * scrcpy-style workaround: AudioRecord pulls its AttributionSource packageName from + * ActivityThread.mBoundApplication.appInfo.packageName. Inside Shizuku's shell + * process that defaults to "com.castla.mirror" (the client APK that was loaded), + * which mismatches our actual runtime uid 2000 — AudioFlinger's ValidatedAttributionSource + * check rejects the combination and AudioRecord.state stays STATE_UNINITIALIZED. + * Overwriting the package name to "com.android.shell" (owned by uid 2000) fixes it. + * + * Reference: genymobile/scrcpy server/src/.../Workarounds.java#fillAppInfo + */ + private fun fillShellAppInfo() { + try { + val atClass = Class.forName("android.app.ActivityThread") + val currentAt = atClass.getDeclaredMethod("currentActivityThread").also { it.isAccessible = true } + val activityThread = currentAt.invoke(null) ?: return + + val appBindDataClass = Class.forName("android.app.ActivityThread\$AppBindData") + val appBindDataCtor = appBindDataClass.getDeclaredConstructor().also { it.isAccessible = true } + val appBindData = appBindDataCtor.newInstance() + + val appInfo = android.content.pm.ApplicationInfo().apply { + packageName = "com.android.shell" + uid = 2000 + } + + val appInfoField = appBindDataClass.getDeclaredField("appInfo").also { it.isAccessible = true } + appInfoField.set(appBindData, appInfo) + + val mBoundApp = atClass.getDeclaredField("mBoundApplication").also { it.isAccessible = true } + mBoundApp.set(activityThread, appBindData) + + Log.i(TAG, "fillShellAppInfo: ActivityThread.mBoundApplication.appInfo.packageName=com.android.shell") + } catch (e: Exception) { + Log.w(TAG, "fillShellAppInfo failed", e) + } + } + + /** + * Temporarily swap the current Application's base Context for our shellContext. + * Returns the original base (to be restored via [restoreApplicationBase]). + * + * This is the path AudioRecord ultimately uses to build its AttributionSource: + * AttributionSource.myAttributionSource() + * → ActivityThread.currentOpPackageName() + * → ActivityThread.currentApplication().getOpPackageName() + * → Application.mBase (ContextWrapper).getOpPackageName() + * + * Rebasing globally at init time crashes Shizuku's ServiceStarter post-init, so we + * apply it only around the AudioRecord construction. + */ + private fun rebaseApplicationToShellContext(): android.content.Context? { + val shell = shellContext ?: return null + return try { + val app = Class.forName("android.app.ActivityThread") + .getDeclaredMethod("currentApplication").also { it.isAccessible = true } + .invoke(null) as? android.app.Application ?: return null + val mBaseField = android.content.ContextWrapper::class.java.getDeclaredField("mBase") + .also { it.isAccessible = true } + val prev = mBaseField.get(app) as? android.content.Context + mBaseField.set(app, shell) + prev + } catch (e: Exception) { + Log.w(TAG, "rebaseApplicationToShellContext failed", e) + null + } + } + + private fun restoreApplicationBase(prev: android.content.Context?) { + if (prev == null) return + try { + val app = Class.forName("android.app.ActivityThread") + .getDeclaredMethod("currentApplication").also { it.isAccessible = true } + .invoke(null) as? android.app.Application ?: return + val mBaseField = android.content.ContextWrapper::class.java.getDeclaredField("mBase") + .also { it.isAccessible = true } + mBaseField.set(app, prev) + } catch (e: Exception) { + Log.w(TAG, "restoreApplicationBase failed", e) + } } private fun tryInitShellContext() { @@ -76,13 +163,34 @@ class PrivilegedService : IPrivilegedService.Stub() { } val systemContext = atClass.getMethod("getSystemContext").invoke(at) as android.content.Context + // Pre-build an AttributionSource matching shell (uid 2000, pkg com.android.shell) + // so AudioFlinger's ValidatedAttributionSourceState accepts the combination. + // The default systemContext.getAttributionSource() reports packageName="android" + // with uid=1000, which mismatches our actual runtime uid 2000 → rejected. + val shellAttribution: Any? = try { + val builderClass = Class.forName("android.content.AttributionSource\$Builder") + val builder = builderClass + .getConstructor(Int::class.javaPrimitiveType) + .newInstance(2000) + builderClass.getMethod("setPackageName", String::class.java) + .invoke(builder, "com.android.shell") + builderClass.getMethod("build").invoke(builder) + } catch (e: Exception) { + Log.w(TAG, "Could not build shell AttributionSource", e) + null + } + // Wrap with "com.android.shell" package name to match Shizuku uid 2000 shellContext = object : android.content.ContextWrapper(systemContext) { override fun getPackageName(): String = "com.android.shell" override fun getOpPackageName(): String = "com.android.shell" override fun getAttributionTag(): String? = null + override fun getAttributionSource(): android.content.AttributionSource { + if (shellAttribution is android.content.AttributionSource) return shellAttribution + return super.getAttributionSource() + } } - Log.i(TAG, "Shell context initialized: pkg=${shellContext?.packageName}") + Log.i(TAG, "Shell context initialized: pkg=${shellContext?.packageName}, attr=${shellAttribution != null}") } catch (e: Exception) { Log.w(TAG, "Failed to init shell context", e) } @@ -767,71 +875,133 @@ class PrivilegedService : IPrivilegedService.Stub() { return false } - // --- System audio capture via REMOTE_SUBMIX (shell uid has CAPTURE_AUDIO_OUTPUT) --- + // --- System audio capture via AudioPolicy loopback (shell uid has MODIFY_AUDIO_ROUTING) --- + // + // Plain AudioRecord(REMOTE_SUBMIX, ...) only captures usages the platform routes to + // REMOTE_SUBMIX by default (MEDIA/GAME/UNKNOWN). Navigation guidance and other + // "restricted" usages are not captured that way. To grab them we register an + // AudioPolicy with a loopback AudioMix whose MixingRule matches every usage we care + // about, then pull frames from policy.createAudioRecordSink(mix). This is only + // possible because the Shizuku privileged service runs as shell, which holds + // MODIFY_AUDIO_ROUTING. On failure we fall back to plain REMOTE_SUBMIX (still + // captures YouTube/games/etc.). @Volatile private var audioCaptureRunning = false private var audioCaptureThread: Thread? = null private var audioCaptureRecord: AudioRecord? = null + private var registeredAudioPolicy: Any? = null override fun startSystemAudioCapture(sampleRate: Int, channels: Int): ParcelFileDescriptor? { stopSystemAudioCapture() - return try { - val channelMask = if (channels == 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO - val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT) - val bufSize = maxOf(minBuf * 2, 8192) - - val record = AudioRecord( - MediaRecorder.AudioSource.REMOTE_SUBMIX, - sampleRate, - channelMask, - AudioFormat.ENCODING_PCM_16BIT, - bufSize - ) + // IMPORTANT: this method is an AIDL binder entry point. Binder.getCallingUid() + // returns the *app* uid (10xxx), not shell (2000). AudioPolicy/AudioRecord's + // permission and attribution checks (MODIFY_AUDIO_ROUTING, CAPTURE_AUDIO_OUTPUT) + // are evaluated against the calling identity — clear it so we look like shell. + val token = Binder.clearCallingIdentity() + // Swap Application.mBase so AudioRecord's AttributionSource reports + // packageName="com.android.shell" matching our uid 2000. Restore immediately + // after init — AudioRecord has already cached its attribution by then. + val prevBase = rebaseApplicationToShellContext() + try { + return doStartSystemAudioCapture(sampleRate, channels) + } catch (e: Exception) { + Log.e(TAG, "Failed to start system audio capture", e) + unregisterAudioPolicy() + return null + } finally { + restoreApplicationBase(prevBase) + Binder.restoreCallingIdentity(token) + } + } - if (record.state != AudioRecord.STATE_INITIALIZED) { - Log.e(TAG, "REMOTE_SUBMIX AudioRecord failed to initialize (state=${record.state})") - record.release() - return null - } + private fun doStartSystemAudioCapture(sampleRate: Int, channels: Int): ParcelFileDescriptor? { + val channelMask = if (channels == 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO + val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT) + val bufSize = maxOf(minBuf * 2, 8192) + + // Try AudioPolicy-based capture first (includes navigation/assistant/alarm/etc.) + val policyRecord = tryCreateAudioPolicyRecord(sampleRate, channels) + val usingPolicy = policyRecord != null + val record = policyRecord ?: buildRemoteSubmixRecord(sampleRate, channelMask, bufSize) + + if (record == null || record.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "Audio capture AudioRecord failed to initialize (state=${record?.state})") + try { record?.release() } catch (_: Exception) {} + unregisterAudioPolicy() + return null + } - val pipe = ParcelFileDescriptor.createPipe() - val readEnd = pipe[0] - val writeEnd = pipe[1] + val pipe = ParcelFileDescriptor.createPipe() + val readEnd = pipe[0] + val writeEnd = pipe[1] - record.startRecording() - audioCaptureRecord = record - audioCaptureRunning = true + record.startRecording() + audioCaptureRecord = record + audioCaptureRunning = true - audioCaptureThread = Thread({ - val pcmBuf = ByteArray(3840) // 20ms at 48kHz stereo 16bit - val output = ParcelFileDescriptor.AutoCloseOutputStream(writeEnd) - try { - while (audioCaptureRunning) { - val read = record.read(pcmBuf, 0, pcmBuf.size) - if (read > 0) { - output.write(pcmBuf, 0, read) - } else if (read < 0) { - Log.w(TAG, "REMOTE_SUBMIX read error: $read") - break - } + audioCaptureThread = Thread({ + val pcmBuf = ByteArray(3840) // 20ms at 48kHz stereo 16bit + val output = ParcelFileDescriptor.AutoCloseOutputStream(writeEnd) + try { + while (audioCaptureRunning) { + val read = record.read(pcmBuf, 0, pcmBuf.size) + if (read > 0) { + output.write(pcmBuf, 0, read) + } else if (read < 0) { + Log.w(TAG, "Audio capture read error: $read") + break } - } catch (e: Exception) { - Log.w(TAG, "REMOTE_SUBMIX capture thread ended", e) - } finally { - try { output.close() } catch (_: Exception) {} } - }, "RemoteSubmix-Capture").also { it.start() } + } catch (e: Exception) { + Log.w(TAG, "Audio capture thread ended", e) + } finally { + try { output.close() } catch (_: Exception) {} + } + }, "SystemAudio-Capture").also { it.start() } + + val mode = if (usingPolicy) "AudioPolicy loopback" else "plain REMOTE_SUBMIX" + Log.i(TAG, "System audio capture started via $mode: ${sampleRate}Hz, ${channels}ch") + return readEnd + } - Log.i(TAG, "REMOTE_SUBMIX audio capture started: ${sampleRate}Hz, ${channels}ch") - readEnd + /** + * Build a plain REMOTE_SUBMIX AudioRecord via the Builder so we can supply shellContext. + * The default AudioRecord constructor uses ActivityThread.currentApplication() which, + * inside a Shizuku-loaded privileged service, reports packageName=com.castla.mirror + * while Process.myUid()=2000 (shell) — AudioFlinger rejects that combination. + */ + private fun buildRemoteSubmixRecord(sampleRate: Int, channelMask: Int, bufSize: Int): AudioRecord? { + return try { + val format = AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelMask) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build() + val builder = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.REMOTE_SUBMIX) + .setAudioFormat(format) + .setBufferSizeInBytes(bufSize) + applyShellContextToBuilder(builder) + builder.build() } catch (e: Exception) { - Log.e(TAG, "Failed to start REMOTE_SUBMIX audio capture", e) + Log.w(TAG, "buildRemoteSubmixRecord failed", e) null } } + /** Inject shellContext into AudioRecord.Builder via reflection — setContext is @hide. */ + private fun applyShellContextToBuilder(builder: AudioRecord.Builder) { + val ctx = shellContext ?: return + try { + val m = AudioRecord.Builder::class.java.getMethod("setContext", android.content.Context::class.java) + m.invoke(builder, ctx) + } catch (e: Exception) { + Log.w(TAG, "setContext(shellContext) on AudioRecord.Builder failed: ${e.message}") + } + } + override fun stopSystemAudioCapture() { audioCaptureRunning = false try { audioCaptureRecord?.stop() } catch (_: Exception) {} @@ -839,6 +1009,128 @@ class PrivilegedService : IPrivilegedService.Stub() { audioCaptureThread = null try { audioCaptureRecord?.release() } catch (_: Exception) {} audioCaptureRecord = null + unregisterAudioPolicy() + } + + /** + * Builds an AudioPolicy with a loopback AudioMix matching every usage we want to + * relay to the browser, registers it, then builds an AudioRecord that captures the + * REMOTE_SUBMIX loopback output from that mix. Uses reflection because the relevant + * AudioPolicy / AudioMix / AudioAttributes.setInternalCapturePreset / + * AudioRecord.Builder.buildAudioRecordForAudioPolicy APIs are all @SystemApi. + * + * We deliberately do NOT call policy.createAudioRecordSink(), which internally + * builds an AudioRecord without a Context — the resulting AttributionSource uses + * ActivityThread.currentApplication()'s packageName (com.castla.mirror), which + * mismatches our shell uid (2000) and gets rejected by AudioFlinger. Building the + * AudioRecord ourselves lets us inject shellContext so attribution reports + * packageName=com.android.shell matching uid 2000. + * + * Returns null on any failure — caller falls back to plain REMOTE_SUBMIX. + */ + private fun tryCreateAudioPolicyRecord(sampleRate: Int, channels: Int): AudioRecord? { + val context = shellContext ?: return null + return try { + val audioManager = context.getSystemService(android.media.AudioManager::class.java) ?: return null + + val mixingRuleBuilderClass = Class.forName("android.media.audiopolicy.AudioMixingRule\$Builder") + val mixBuilderClass = Class.forName("android.media.audiopolicy.AudioMix\$Builder") + val policyBuilderClass = Class.forName("android.media.audiopolicy.AudioPolicy\$Builder") + val mixingRuleClass = Class.forName("android.media.audiopolicy.AudioMixingRule") + val audioMixClass = Class.forName("android.media.audiopolicy.AudioMix") + val audioPolicyClass = Class.forName("android.media.audiopolicy.AudioPolicy") + + val ruleMatchAttributeUsage = 1 // AudioMixingRule.RULE_MATCH_ATTRIBUTE_USAGE + val routeFlagLoopBack = 2 // AudioMix.ROUTE_FLAG_LOOP_BACK + + val ruleBuilder = mixingRuleBuilderClass.getConstructor().newInstance() + val addRule = mixingRuleBuilderClass.getMethod( + "addRule", AudioAttributes::class.java, Int::class.javaPrimitiveType + ) + + val usages = intArrayOf( + AudioAttributes.USAGE_UNKNOWN, + AudioAttributes.USAGE_MEDIA, + AudioAttributes.USAGE_GAME, + AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE, + AudioAttributes.USAGE_ASSISTANT, + AudioAttributes.USAGE_ASSISTANCE_SONIFICATION, + AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY, + AudioAttributes.USAGE_ALARM, + AudioAttributes.USAGE_NOTIFICATION, + AudioAttributes.USAGE_NOTIFICATION_RINGTONE, + AudioAttributes.USAGE_NOTIFICATION_EVENT, + AudioAttributes.USAGE_VOICE_COMMUNICATION + ) + var matchedAny = false + for (usage in usages) { + try { + val attr = AudioAttributes.Builder().setUsage(usage).build() + addRule.invoke(ruleBuilder, attr, ruleMatchAttributeUsage) + matchedAny = true + } catch (e: Exception) { + Log.w(TAG, "AudioMixingRule skip usage=$usage: ${e.message}") + } + } + if (!matchedAny) return null + val mixingRule = mixingRuleBuilderClass.getMethod("build").invoke(ruleBuilder) + + val channelMask = if (channels == 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO + val format = AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelMask) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build() + + val mixBuilder = mixBuilderClass.getConstructor(mixingRuleClass).newInstance(mixingRule) + mixBuilderClass.getMethod("setFormat", AudioFormat::class.java).invoke(mixBuilder, format) + mixBuilderClass.getMethod("setRouteFlags", Int::class.javaPrimitiveType).invoke(mixBuilder, routeFlagLoopBack) + val audioMix = mixBuilderClass.getMethod("build").invoke(mixBuilder) + + val policyBuilder = policyBuilderClass + .getConstructor(android.content.Context::class.java) + .newInstance(context) + policyBuilderClass.getMethod("addMix", audioMixClass).invoke(policyBuilder, audioMix) + val policy = policyBuilderClass.getMethod("build").invoke(policyBuilder) + + val registerResult = audioManager.javaClass + .getMethod("registerAudioPolicy", audioPolicyClass) + .invoke(audioManager, policy) as Int + if (registerResult != 0) { + Log.w(TAG, "registerAudioPolicy failed with code $registerResult") + return null + } + registeredAudioPolicy = policy + + val record = audioPolicyClass + .getMethod("createAudioRecordSink", audioMixClass) + .invoke(policy, audioMix) as? AudioRecord + if (record == null) { + Log.w(TAG, "createAudioRecordSink returned null") + unregisterAudioPolicy() + return null + } + record + } catch (e: Exception) { + Log.w(TAG, "AudioPolicy capture setup failed, will fall back", e) + unregisterAudioPolicy() + null + } + } + + private fun unregisterAudioPolicy() { + val policy = registeredAudioPolicy ?: return + registeredAudioPolicy = null + try { + val context = shellContext ?: return + val audioManager = context.getSystemService(android.media.AudioManager::class.java) ?: return + val audioPolicyClass = Class.forName("android.media.audiopolicy.AudioPolicy") + audioManager.javaClass + .getMethod("unregisterAudioPolicy", audioPolicyClass) + .invoke(audioManager, policy) + } catch (e: Exception) { + Log.w(TAG, "Failed to unregister audio policy", e) + } } override fun destroy() { diff --git a/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt b/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt index 7ced411..14339f4 100644 --- a/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt +++ b/app/src/main/java/com/castla/mirror/ui/SettingsScreen.kt @@ -269,47 +269,6 @@ fun SettingsScreen( ) ) } - - // Mute local audio toggle — only shown when audio is enabled - if (settings.audioEnabled) { - Spacer(modifier = Modifier.height(12.dp)) - HorizontalDivider(color = Color.White.copy(alpha = 0.1f)) - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_mute_local_audio), - style = MaterialTheme.typography.bodyLarge, - color = Color.White, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.settings_mute_local_audio_description), - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f) - ) - } - Switch( - checked = settings.muteLocalAudio, - onCheckedChange = { enabled -> - if (!isStreaming) onSettingsChanged(settings.copy(muteLocalAudio = enabled)) - }, - enabled = !isStreaming, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = Color(0xFF2979FF), - uncheckedThumbColor = Color.White.copy(alpha = 0.7f), - uncheckedTrackColor = Color.White.copy(alpha = 0.2f), - uncheckedBorderColor = Color.Transparent - ) - ) - } - } } Spacer(modifier = Modifier.height(20.dp)) @@ -516,7 +475,7 @@ fun SettingSection(title: String, content: @Composable () -> Unit) { fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 4.dp, bottom = 12.dp) ) - Box( + Column( modifier = Modifier .fillMaxWidth() .glassCard() diff --git a/app/src/main/java/com/castla/mirror/ui/SettingsState.kt b/app/src/main/java/com/castla/mirror/ui/SettingsState.kt index dcaf5f3..ad9a271 100644 --- a/app/src/main/java/com/castla/mirror/ui/SettingsState.kt +++ b/app/src/main/java/com/castla/mirror/ui/SettingsState.kt @@ -9,7 +9,6 @@ data class StreamSettings( val maxResolution: Resolution = Resolution.AUTO, val fps: Int = FPS_AUTO, val audioEnabled: Boolean = false, - val muteLocalAudio: Boolean = true, val mirroringMode: MirroringMode = MirroringMode.FULL_SCREEN, val targetAppPackage: String = "", val targetAppLabel: String = "", @@ -35,7 +34,6 @@ data class StreamSettings( private const val KEY_MIRRORING_MODE = "mirroring_mode" private const val KEY_TARGET_APP_PACKAGE = "target_app_package" private const val KEY_TARGET_APP_LABEL = "target_app_label" - private const val KEY_MUTE_LOCAL_AUDIO = "mute_local_audio" private const val KEY_AUTO_HOTSPOT = "auto_hotspot" /** Sentinel value indicating auto FPS mode. Must not collide with real FPS values. */ @@ -55,7 +53,6 @@ data class StreamSettings( maxResolution = resolution, fps = fps, audioEnabled = prefs.getBoolean(KEY_AUDIO, false), - muteLocalAudio = prefs.getBoolean(KEY_MUTE_LOCAL_AUDIO, true), mirroringMode = try { MirroringMode.valueOf(prefs.getString(KEY_MIRRORING_MODE, MirroringMode.FULL_SCREEN.name)!!) } catch (_: Exception) { MirroringMode.FULL_SCREEN }, @@ -70,7 +67,6 @@ data class StreamSettings( .putString(KEY_RESOLUTION, settings.maxResolution.name) .putInt(KEY_FPS, settings.fps) .putBoolean(KEY_AUDIO, settings.audioEnabled) - .putBoolean(KEY_MUTE_LOCAL_AUDIO, settings.muteLocalAudio) .putString(KEY_MIRRORING_MODE, settings.mirroringMode.name) .putString(KEY_TARGET_APP_PACKAGE, settings.targetAppPackage) .putString(KEY_TARGET_APP_LABEL, settings.targetAppLabel) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a4a427e..8aef342 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -57,8 +57,6 @@ 오디오 (실험적) 기기 오디오 스트리밍 Android 10 이상 필요. 시스템 출력을 캡처합니다. - 로컬 오디오 음소거 - Tesla로 오디오 스트리밍 중 폰 스피커를 음소거합니다. 현재 설정 전체 화면 , 오디오 켜짐 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 019b429..6be9b12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,8 +52,6 @@ Audio (Experimental) Stream device audio Requires Android 10+. Captures system output. - Mute local audio - Silence phone speaker while streaming audio to Tesla. Current Configuration Full Screen , audio on diff --git a/app/src/test/java/com/castla/mirror/policy/AudioPolicyTest.kt b/app/src/test/java/com/castla/mirror/policy/AudioPolicyTest.kt index 61da4e4..4c6e372 100644 --- a/app/src/test/java/com/castla/mirror/policy/AudioPolicyTest.kt +++ b/app/src/test/java/com/castla/mirror/policy/AudioPolicyTest.kt @@ -10,9 +10,8 @@ class AudioPolicyTest { requestedCodec: String? = null, currentCodec: String? = null, browserConnected: Boolean = true, - muteLocalAudio: Boolean = false, captureActive: Boolean = false - ) = AudioPolicyInput(audioEnabled, requestedCodec, currentCodec, browserConnected, muteLocalAudio, captureActive) + ) = AudioPolicyInput(audioEnabled, requestedCodec, currentCodec, browserConnected, captureActive) // ── Audio disabled — never capture ── @@ -20,7 +19,6 @@ class AudioPolicyTest { fun `audio disabled - no capture regardless of codec request`() { val decision = AudioPolicy.evaluate(input(audioEnabled = false, requestedCodec = "opus")) assertFalse(decision.shouldCapture) - assertFalse(decision.shouldMuteLocal) assertNull(decision.codecMode) } @@ -67,33 +65,6 @@ class AudioPolicyTest { assertFalse(decision.shouldCapture) } - // ── Local mute policy ── - - @Test - fun `mute local off by default - no mute`() { - val decision = AudioPolicy.evaluate(input(audioEnabled = true, browserConnected = true, muteLocalAudio = false)) - assertFalse(decision.shouldMuteLocal) - } - - @Test - fun `mute local opt-in - mute only when capturing`() { - val decision = AudioPolicy.evaluate(input(audioEnabled = true, browserConnected = true, muteLocalAudio = true)) - assertTrue(decision.shouldCapture) - assertTrue(decision.shouldMuteLocal) - } - - @Test - fun `mute local opt-in but audio disabled - no mute`() { - val decision = AudioPolicy.evaluate(input(audioEnabled = false, muteLocalAudio = true)) - assertFalse(decision.shouldMuteLocal) - } - - @Test - fun `mute local opt-in but browser disconnected - no mute`() { - val decision = AudioPolicy.evaluate(input(audioEnabled = true, browserConnected = false, muteLocalAudio = true)) - assertFalse(decision.shouldMuteLocal) - } - // ── Restart detection ── @Test @@ -165,8 +136,8 @@ class AudioPolicyTest { @Test fun `policy is pure function - same inputs always produce same outputs`() { - val inputA = input(audioEnabled = true, browserConnected = true, muteLocalAudio = false) - val inputB = input(audioEnabled = true, browserConnected = true, muteLocalAudio = false) + val inputA = input(audioEnabled = true, browserConnected = true) + val inputB = input(audioEnabled = true, browserConnected = true) assertEquals(AudioPolicy.evaluate(inputA), AudioPolicy.evaluate(inputB)) } } diff --git a/app/src/test/java/com/castla/mirror/service/AudioCaptureOrchestratorTest.kt b/app/src/test/java/com/castla/mirror/service/AudioCaptureOrchestratorTest.kt index d9c01c5..da3fc12 100644 --- a/app/src/test/java/com/castla/mirror/service/AudioCaptureOrchestratorTest.kt +++ b/app/src/test/java/com/castla/mirror/service/AudioCaptureOrchestratorTest.kt @@ -19,7 +19,6 @@ class AudioCaptureOrchestratorTest { orch = AudioCaptureOrchestrator(object : AudioCaptureOrchestrator.Actions { override fun startCapture(codec: String?) { log.add("start:${codec ?: "default"}") } override fun stopCapture() { log.add("stop") } - override fun applyMute(shouldMute: Boolean) { log.add("mute:$shouldMute") } override fun grantAudioPermission() { log.add("grant") } override fun scheduleDeferredStart(delayMs: Long): Any? { deferScheduled = true @@ -156,39 +155,10 @@ class AudioCaptureOrchestratorTest { assertTrue(log.contains("start:pcm")) } - // ── Mute policy ── - - @Test - fun `mute off by default`() { - startCapturing("opus") - assertTrue(log.contains("mute:false")) - assertFalse(log.contains("mute:true")) - } - - @Test - fun `mute opt-in applies when capturing`() { - orch.audioEnabled = true - orch.browserConnected = true - orch.muteLocalAudio = true - orch.currentCodec = "opus" - orch.ensure(codecOverride = "opus") - assertTrue(log.contains("mute:true")) - } - - @Test - fun `mute opt-in does not apply when audio disabled`() { - orch.audioEnabled = false - orch.browserConnected = true - orch.muteLocalAudio = true - orch.ensure() - assertFalse(log.contains("mute:true")) - } - // ── Browser disconnect stops capture ── @Test - fun `browser disconnect stops capture and restores mute`() { - orch.muteLocalAudio = true + fun `browser disconnect stops capture`() { startCapturing("opus") log.clear() @@ -197,7 +167,6 @@ class AudioCaptureOrchestratorTest { assertEquals(AudioCaptureOrchestrator.EnsureResult.STOPPED, result) assertFalse(orch.captureActive) assertTrue(log.contains("stop")) - assertTrue(log.contains("mute:false")) } // ── Reconnect ── @@ -228,7 +197,6 @@ class AudioCaptureOrchestratorTest { assertNull(orch.currentCodec) assertFalse(orch.audioSocketConnected) assertTrue(log.contains("stop")) - assertTrue(log.contains("mute:false")) } // ── Helper ── diff --git a/app/src/test/java/com/castla/mirror/ui/StreamSettingsTest.kt b/app/src/test/java/com/castla/mirror/ui/StreamSettingsTest.kt index 8be0b34..ec02c7d 100644 --- a/app/src/test/java/com/castla/mirror/ui/StreamSettingsTest.kt +++ b/app/src/test/java/com/castla/mirror/ui/StreamSettingsTest.kt @@ -29,7 +29,6 @@ class StreamSettingsTest { assertTrue(settings.isAutoResolution) assertTrue(settings.isAutoFps) assertFalse(settings.audioEnabled) - assertFalse(settings.muteLocalAudio) } @Test @@ -40,7 +39,6 @@ class StreamSettingsTest { assertTrue(settings.isAutoResolution) assertTrue(settings.isAutoFps) assertFalse(settings.audioEnabled) - assertFalse(settings.muteLocalAudio) } @Test