package com.wasp.cardash_launcher import android.app.Activity import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager import android.util.Log import android.view.KeyEvent import android.view.WindowManager import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel /** Battery, Wi-Fi and media-volume bridge. */ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHandler { companion object { const val CHANNEL = "com.wasp.cardash_launcher/system" private const val VOLUME_TAG = "CarDashVolume" } private var channel: MethodChannel? = null private var lastVolumeLog: String? = null private val syu = SyuVolumeBridge(activity.applicationContext) fun attach(messenger: BinaryMessenger) { syu.start() channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) } } fun dispose() { syu.dispose() channel?.setMethodCallHandler(null) channel = null } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getStatus" -> result.success(readStatus()) "getVolume" -> result.success(readVolume()) "isMusicPlaying" -> result.success(isMusicPlaying()) "setKeepScreenOn" -> { val enabled = call.argument("enabled") if (enabled == null) { result.error("bad_arguments", "An enabled flag is required.", null) return } setKeepScreenOn(enabled) result.success(true) } "mediaKey" -> { val action = call.argument("action") if (action == null) { result.error("bad_arguments", "A media key action is required.", null) return } result.success(sendMediaKey(action)) } "setVolume" -> { val index = call.argument("index") if (index == null) { result.error("bad_arguments", "A volume index is required.", null) return } result.success(setVolume(index)) } else -> result.notImplemented() } } private fun readStatus(): Map { val batteryIntent = activity.registerReceiver( null, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ) val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 val percentage = if (level >= 0 && scale > 0) (level * 100f / scale).toInt() else 0 val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL val wifi = try { val connectivity = activity.getSystemService(ConnectivityManager::class.java) val network = connectivity?.activeNetwork val capabilities = network?.let { connectivity.getNetworkCapabilities(it) } capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true } catch (_: Throwable) { false } return mapOf( "batteryLevel" to percentage, "charging" to charging, "wifi" to wifi, ) } private fun audioManager(): AudioManager = activity.getSystemService(AudioManager::class.java) private fun readVolume(): Map { val audio = audioManager() // Until the MCU reports its current level we keep showing Android's // stream value; an absolute write based on the wrong scale could jump // the amplifier to full volume. val syuActive = syu.connected && syu.level != null val max = if (syuActive) { SyuVolumeBridge.MAX_LEVEL } else { audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } val min = if (syuActive) 0 else minStreamVolume(audio, AudioManager.STREAM_MUSIC) val index = if (syuActive) { syu.level ?: 0 } else { streamVolume(audio, AudioManager.STREAM_MUSIC) } val snapshot = mapOf( "index" to index, "max" to max, "min" to min, "fixed" to (if (syuActive) false else audio.isVolumeFixed), "source" to when { syuActive -> "syu" syu.connected -> "syu-wait" else -> "system" }, "vendor" to syu.status, "streams" to mapOf( "music" to streamVolume(audio, AudioManager.STREAM_MUSIC), "voice" to streamVolume(audio, AudioManager.STREAM_VOICE_CALL), "ring" to streamVolume(audio, AudioManager.STREAM_RING), "alarm" to streamVolume(audio, AudioManager.STREAM_ALARM), "notif" to streamVolume(audio, AudioManager.STREAM_NOTIFICATION), "system" to streamVolume(audio, AudioManager.STREAM_SYSTEM), "dtmf" to streamVolume(audio, AudioManager.STREAM_DTMF), "access" to streamVolume(audio, AudioManager.STREAM_ACCESSIBILITY), ), ) val log = snapshot.toString() if (log != lastVolumeLog) { lastVolumeLog = log Log.i(VOLUME_TAG, "read $log") } return snapshot } private fun streamVolume(audio: AudioManager, stream: Int): Int { return try { audio.getStreamVolume(stream) } catch (_: Throwable) { -1 } } private fun minStreamVolume(audio: AudioManager, stream: Int): Int { return try { audio.getStreamMinVolume(stream) } catch (_: Throwable) { 0 } } private fun isMusicPlaying(): Boolean { return try { audioManager().isMusicActive } catch (_: Throwable) { false } } private fun setKeepScreenOn(enabled: Boolean) { activity.runOnUiThread { try { if (enabled) { activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } catch (_: Throwable) { // Window flags are best-effort. } } } private fun sendMediaKey(action: String): Boolean { val keyCode = when (action) { "play" -> KeyEvent.KEYCODE_MEDIA_PLAY "pause" -> KeyEvent.KEYCODE_MEDIA_PAUSE "playPause" -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE else -> return false } return try { val audio = audioManager() audio.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) audio.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) true } catch (_: Throwable) { false } } private fun setVolume(index: Int): Boolean { // On FYT/Syu units the amplifier ignores Android's stream volume, so // hand the change to the MCU app first. if (syu.connected && syu.level != null) { val target = index.coerceIn(0, SyuVolumeBridge.MAX_LEVEL) if (syu.setLevel(target)) { Log.i(VOLUME_TAG, "set syu target=$target") return true } } return try { val audio = audioManager() // A fixed-volume policy means the car amplifier owns the level; // software volume changes are intentionally ignored by Android. if (audio.isVolumeFixed) { Log.i(VOLUME_TAG, "set $index rejected: fixed volume") return false } val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val target = index.coerceIn(0, max) val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC) audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) var after = audio.getStreamVolume(AudioManager.STREAM_MUSIC) Log.i(VOLUME_TAG, "set target=$target before=$before after=$after max=$max") if (after == target) return true if (after == before) { // Some head units only route a volume change through their own // panel; asking Android to show it uses a different code path // than a silent write. audio.setStreamVolume( AudioManager.STREAM_MUSIC, target, AudioManager.FLAG_SHOW_UI, ) after = audio.getStreamVolume(AudioManager.STREAM_MUSIC) Log.i(VOLUME_TAG, "set with ui target=$target after=$after") if (after == target) return true } // Some head units ignore absolute volume writes but accept the // same relative steps as the hardware volume keys. val result = if (after == before) adjustTo(audio, target, after) else false Log.i(VOLUME_TAG, "relative fallback result=$result now=${streamVolume(audio, AudioManager.STREAM_MUSIC)}") result } catch (error: Throwable) { Log.i(VOLUME_TAG, "set $index failed: $error") false } } private fun adjustTo(audio: AudioManager, target: Int, start: Int): Boolean { var current = start var remainingSteps = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 2 + 2 while (current != target && remainingSteps-- > 0) { audio.adjustStreamVolume( AudioManager.STREAM_MUSIC, if (target > current) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER, 0, ) val next = audio.getStreamVolume(AudioManager.STREAM_MUSIC) if (next == current) return false current = next } return current == target } }