Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) を参照してください。

Expand Down
7 changes: 6 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 파일을 참조하세요.

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
1 change: 0 additions & 1 deletion app/src/main/java/com/castla/mirror/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 2 additions & 10 deletions app/src/main/java/com/castla/mirror/policy/AudioPolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ data class AudioPolicyInput(
val requestedCodec: String?,
val currentCodec: String?,
val browserConnected: Boolean,
val muteLocalAudio: Boolean,
val captureActive: Boolean
)

Expand All @@ -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
Expand All @@ -46,7 +39,6 @@ object AudioPolicy {
return AudioPolicyDecision(
shouldCapture = shouldCapture,
codecMode = codecMode,
shouldMuteLocal = shouldMuteLocal,
restartRequired = restartRequired
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ 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?
fun cancelDeferredStart(handle: Any?)
}

var audioEnabled = false
var muteLocalAudio = false
var browserConnected = false
var currentCodec: String? = null
var captureActive = false
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -115,7 +111,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) {
}
currentCodec = null
audioSocketConnected = false
actions.applyMute(false)
}

private fun cancelDefer() {
Expand All @@ -131,7 +126,6 @@ class AudioCaptureOrchestrator(private val actions: Actions) {
requestedCodec = codecOverride,
currentCodec = currentCodec,
browserConnected = browserConnected,
muteLocalAudio = muteLocalAudio,
captureActive = captureActive
))

Expand All @@ -142,19 +136,16 @@ class AudioCaptureOrchestrator(private val actions: Actions) {
if (!decision.shouldCapture) return EnsureResult.STOPPED

if (captureActive && !decision.restartRequired) {
actions.applyMute(decision.shouldMuteLocal)
return EnsureResult.KEPT
}

// Stop existing if restarting
if (captureActive) {
actions.stopCapture()
captureActive = false
actions.applyMute(false)
}

actions.grantAudioPermission()
actions.applyMute(decision.shouldMuteLocal)

if (codecOverride != null) currentCodec = codecOverride

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -944,8 +939,7 @@ class MirrorForegroundService : Service() {
resultCode: Int,
data: Intent,
fps: Int,
audioEnabled: Boolean,
muteLocalAudio: Boolean = false
audioEnabled: Boolean
) {
try {
MirrorDiagnostics.onSessionStart()
Expand Down Expand Up @@ -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?) {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -3073,79 +3062,6 @@ class MirrorForegroundService : Service() {
}
}

// Saved volumes for all streams we mute (stream type -> saved volume)
private val savedVolumes = mutableMapOf<Int, Int>()

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
Expand Down
Loading