SystemBridge.kt 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. package com.wasp.cardash_launcher
  2. import android.app.Activity
  3. import android.content.Intent
  4. import android.content.IntentFilter
  5. import android.media.AudioManager
  6. import android.net.ConnectivityManager
  7. import android.net.NetworkCapabilities
  8. import android.os.BatteryManager
  9. import android.util.Log
  10. import android.view.KeyEvent
  11. import android.view.WindowManager
  12. import io.flutter.plugin.common.BinaryMessenger
  13. import io.flutter.plugin.common.MethodCall
  14. import io.flutter.plugin.common.MethodChannel
  15. /** Battery, Wi-Fi and media-volume bridge. */
  16. class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
  17. companion object {
  18. const val CHANNEL = "com.wasp.cardash_launcher/system"
  19. private const val VOLUME_TAG = "CarDashVolume"
  20. }
  21. private var channel: MethodChannel? = null
  22. private var lastVolumeLog: String? = null
  23. private val syu = SyuVolumeBridge(activity.applicationContext)
  24. fun attach(messenger: BinaryMessenger) {
  25. syu.start()
  26. channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
  27. }
  28. fun dispose() {
  29. syu.dispose()
  30. channel?.setMethodCallHandler(null)
  31. channel = null
  32. }
  33. override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
  34. when (call.method) {
  35. "getStatus" -> result.success(readStatus())
  36. "getVolume" -> result.success(readVolume())
  37. "isMusicPlaying" -> result.success(isMusicPlaying())
  38. "setKeepScreenOn" -> {
  39. val enabled = call.argument<Boolean>("enabled")
  40. if (enabled == null) {
  41. result.error("bad_arguments", "An enabled flag is required.", null)
  42. return
  43. }
  44. setKeepScreenOn(enabled)
  45. result.success(true)
  46. }
  47. "mediaKey" -> {
  48. val action = call.argument<String>("action")
  49. if (action == null) {
  50. result.error("bad_arguments", "A media key action is required.", null)
  51. return
  52. }
  53. result.success(sendMediaKey(action))
  54. }
  55. "setVolume" -> {
  56. val index = call.argument<Int>("index")
  57. if (index == null) {
  58. result.error("bad_arguments", "A volume index is required.", null)
  59. return
  60. }
  61. result.success(setVolume(index))
  62. }
  63. else -> result.notImplemented()
  64. }
  65. }
  66. private fun readStatus(): Map<String, Any?> {
  67. val batteryIntent = activity.registerReceiver(
  68. null,
  69. IntentFilter(Intent.ACTION_BATTERY_CHANGED),
  70. )
  71. val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
  72. val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
  73. val percentage = if (level >= 0 && scale > 0) (level * 100f / scale).toInt() else 0
  74. val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
  75. val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
  76. status == BatteryManager.BATTERY_STATUS_FULL
  77. val wifi = try {
  78. val connectivity = activity.getSystemService(ConnectivityManager::class.java)
  79. val network = connectivity?.activeNetwork
  80. val capabilities = network?.let { connectivity.getNetworkCapabilities(it) }
  81. capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
  82. } catch (_: Throwable) {
  83. false
  84. }
  85. return mapOf(
  86. "batteryLevel" to percentage,
  87. "charging" to charging,
  88. "wifi" to wifi,
  89. )
  90. }
  91. private fun audioManager(): AudioManager =
  92. activity.getSystemService(AudioManager::class.java)
  93. private fun readVolume(): Map<String, Any?> {
  94. val audio = audioManager()
  95. // Until the MCU reports its current level we keep showing Android's
  96. // stream value; an absolute write based on the wrong scale could jump
  97. // the amplifier to full volume.
  98. val syuActive = syu.connected && syu.level != null
  99. val max = if (syuActive) {
  100. SyuVolumeBridge.MAX_LEVEL
  101. } else {
  102. audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
  103. }
  104. val min = if (syuActive) 0 else minStreamVolume(audio, AudioManager.STREAM_MUSIC)
  105. val index = if (syuActive) {
  106. syu.level ?: 0
  107. } else {
  108. streamVolume(audio, AudioManager.STREAM_MUSIC)
  109. }
  110. val snapshot =
  111. mapOf(
  112. "index" to index,
  113. "max" to max,
  114. "min" to min,
  115. "fixed" to (if (syuActive) false else audio.isVolumeFixed),
  116. "source" to when {
  117. syuActive -> "syu"
  118. syu.connected -> "syu-wait"
  119. else -> "system"
  120. },
  121. "vendor" to syu.status,
  122. "streams" to
  123. mapOf(
  124. "music" to streamVolume(audio, AudioManager.STREAM_MUSIC),
  125. "voice" to streamVolume(audio, AudioManager.STREAM_VOICE_CALL),
  126. "ring" to streamVolume(audio, AudioManager.STREAM_RING),
  127. "alarm" to streamVolume(audio, AudioManager.STREAM_ALARM),
  128. "notif" to streamVolume(audio, AudioManager.STREAM_NOTIFICATION),
  129. "system" to streamVolume(audio, AudioManager.STREAM_SYSTEM),
  130. "dtmf" to streamVolume(audio, AudioManager.STREAM_DTMF),
  131. "access" to streamVolume(audio, AudioManager.STREAM_ACCESSIBILITY),
  132. ),
  133. )
  134. val log = snapshot.toString()
  135. if (log != lastVolumeLog) {
  136. lastVolumeLog = log
  137. Log.i(VOLUME_TAG, "read $log")
  138. }
  139. return snapshot
  140. }
  141. private fun streamVolume(audio: AudioManager, stream: Int): Int {
  142. return try {
  143. audio.getStreamVolume(stream)
  144. } catch (_: Throwable) {
  145. -1
  146. }
  147. }
  148. private fun minStreamVolume(audio: AudioManager, stream: Int): Int {
  149. return try {
  150. audio.getStreamMinVolume(stream)
  151. } catch (_: Throwable) {
  152. 0
  153. }
  154. }
  155. private fun isMusicPlaying(): Boolean {
  156. return try {
  157. audioManager().isMusicActive
  158. } catch (_: Throwable) {
  159. false
  160. }
  161. }
  162. private fun setKeepScreenOn(enabled: Boolean) {
  163. activity.runOnUiThread {
  164. try {
  165. if (enabled) {
  166. activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
  167. } else {
  168. activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
  169. }
  170. } catch (_: Throwable) {
  171. // Window flags are best-effort.
  172. }
  173. }
  174. }
  175. private fun sendMediaKey(action: String): Boolean {
  176. val keyCode = when (action) {
  177. "play" -> KeyEvent.KEYCODE_MEDIA_PLAY
  178. "pause" -> KeyEvent.KEYCODE_MEDIA_PAUSE
  179. "playPause" -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
  180. else -> return false
  181. }
  182. return try {
  183. val audio = audioManager()
  184. audio.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
  185. audio.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
  186. true
  187. } catch (_: Throwable) {
  188. false
  189. }
  190. }
  191. private fun setVolume(index: Int): Boolean {
  192. // On FYT/Syu units the amplifier ignores Android's stream volume, so
  193. // hand the change to the MCU app first.
  194. if (syu.connected && syu.level != null) {
  195. val target = index.coerceIn(0, SyuVolumeBridge.MAX_LEVEL)
  196. if (syu.setLevel(target)) {
  197. Log.i(VOLUME_TAG, "set syu target=$target")
  198. return true
  199. }
  200. }
  201. return try {
  202. val audio = audioManager()
  203. // A fixed-volume policy means the car amplifier owns the level;
  204. // software volume changes are intentionally ignored by Android.
  205. if (audio.isVolumeFixed) {
  206. Log.i(VOLUME_TAG, "set $index rejected: fixed volume")
  207. return false
  208. }
  209. val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
  210. val target = index.coerceIn(0, max)
  211. val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
  212. audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
  213. var after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
  214. Log.i(VOLUME_TAG, "set target=$target before=$before after=$after max=$max")
  215. if (after == target) return true
  216. if (after == before) {
  217. // Some head units only route a volume change through their own
  218. // panel; asking Android to show it uses a different code path
  219. // than a silent write.
  220. audio.setStreamVolume(
  221. AudioManager.STREAM_MUSIC,
  222. target,
  223. AudioManager.FLAG_SHOW_UI,
  224. )
  225. after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
  226. Log.i(VOLUME_TAG, "set with ui target=$target after=$after")
  227. if (after == target) return true
  228. }
  229. // Some head units ignore absolute volume writes but accept the
  230. // same relative steps as the hardware volume keys.
  231. val result = if (after == before) adjustTo(audio, target, after) else false
  232. Log.i(VOLUME_TAG, "relative fallback result=$result now=${streamVolume(audio, AudioManager.STREAM_MUSIC)}")
  233. result
  234. } catch (error: Throwable) {
  235. Log.i(VOLUME_TAG, "set $index failed: $error")
  236. false
  237. }
  238. }
  239. private fun adjustTo(audio: AudioManager, target: Int, start: Int): Boolean {
  240. var current = start
  241. var remainingSteps = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 2 + 2
  242. while (current != target && remainingSteps-- > 0) {
  243. audio.adjustStreamVolume(
  244. AudioManager.STREAM_MUSIC,
  245. if (target > current) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER,
  246. 0,
  247. )
  248. val next = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
  249. if (next == current) return false
  250. current = next
  251. }
  252. return current == target
  253. }
  254. }