|
|
@@ -0,0 +1,266 @@
|
|
|
+package com.wasp.cardash_launcher
|
|
|
+
|
|
|
+import android.content.ComponentName
|
|
|
+import android.content.Context
|
|
|
+import android.content.Intent
|
|
|
+import android.content.ServiceConnection
|
|
|
+import android.os.Binder
|
|
|
+import android.os.Handler
|
|
|
+import android.os.IBinder
|
|
|
+import android.os.Looper
|
|
|
+import android.os.Parcel
|
|
|
+import android.util.Log
|
|
|
+
|
|
|
+/**
|
|
|
+ * Master volume for FYT/Syu head units (Joying, Junsun, Teyes, Atoto and other
|
|
|
+ * Chinese firmware built on the Syu MCU).
|
|
|
+ *
|
|
|
+ * On these units the media stream is not what the amplifier uses: Android's
|
|
|
+ * `AudioManager` writes are accepted by the framework but the actual gain is
|
|
|
+ * owned by the MCU, so apps cannot change the audible volume that way. The
|
|
|
+ * stock `com.syu.ms` system app exposes the sound module over its binder
|
|
|
+ * `com.syu.ms.toolkit` service and applies command 1 as the absolute master
|
|
|
+ * volume (0..[MAX_LEVEL]). It is exported without a permission on these
|
|
|
+ * builds, which is how other third-party launchers control the volume too.
|
|
|
+ *
|
|
|
+ * Everything here is best-effort: when the package or the module is missing
|
|
|
+ * the bridge stays disconnected and [SystemBridge] falls back to the regular
|
|
|
+ * `AudioManager` path. No root, no extra permission, no manifest change.
|
|
|
+ */
|
|
|
+class SyuVolumeBridge(private val context: Context) {
|
|
|
+
|
|
|
+ companion object {
|
|
|
+ private const val TAG = "CarDashVolume"
|
|
|
+ private const val PACKAGE = "com.syu.ms"
|
|
|
+ private const val SERVICE = "app.ToolkitService"
|
|
|
+ private const val ACTION = "com.syu.ms.toolkit"
|
|
|
+ private const val MODULE_SOUND = 4
|
|
|
+ private const val CMD_VOLUME = 0
|
|
|
+ private const val UPDATE_VOLUME = 2
|
|
|
+ private const val UPDATE_MUTE = 3
|
|
|
+ private const val TOOLKIT_DESCRIPTOR = "com.syu.ipc.IRemoteToolkit"
|
|
|
+ private const val MODULE_DESCRIPTOR = "com.syu.ipc.IRemoteModule"
|
|
|
+ private const val CALLBACK_DESCRIPTOR = "com.syu.ipc.IModuleCallback"
|
|
|
+ private const val RETRY_MS = 5_000L
|
|
|
+ private const val CONNECT_TIMEOUT_MS = 4_000L
|
|
|
+
|
|
|
+ /** FYT's default master volume range; the MCU clamps lower on some cars. */
|
|
|
+ const val MAX_LEVEL = 36
|
|
|
+ }
|
|
|
+
|
|
|
+ @Volatile
|
|
|
+ var level: Int? = null
|
|
|
+ private set
|
|
|
+
|
|
|
+ @Volatile
|
|
|
+ var muted: Boolean = false
|
|
|
+ private set
|
|
|
+
|
|
|
+ @Volatile
|
|
|
+ var connected: Boolean = false
|
|
|
+ private set
|
|
|
+
|
|
|
+ /** Short state label surfaced in the on-screen diagnostics. */
|
|
|
+ @Volatile
|
|
|
+ var status: String = "off"
|
|
|
+ private set
|
|
|
+
|
|
|
+ private val handler = Handler(Looper.getMainLooper())
|
|
|
+ private var module: IBinder? = null
|
|
|
+ private var bound = false
|
|
|
+ private var disposed = false
|
|
|
+
|
|
|
+ private val callback = object : Binder() {
|
|
|
+ init {
|
|
|
+ attachInterface(null, CALLBACK_DESCRIPTOR)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onTransact(
|
|
|
+ code: Int,
|
|
|
+ data: Parcel,
|
|
|
+ reply: Parcel?,
|
|
|
+ flags: Int,
|
|
|
+ ): Boolean {
|
|
|
+ if (code == 1) {
|
|
|
+ try {
|
|
|
+ data.enforceInterface(CALLBACK_DESCRIPTOR)
|
|
|
+ val update = data.readInt()
|
|
|
+ val ints = data.createIntArray()
|
|
|
+ data.createFloatArray()
|
|
|
+ data.createStringArray()
|
|
|
+ when (update) {
|
|
|
+ UPDATE_VOLUME -> {
|
|
|
+ val next = ints?.firstOrNull()
|
|
|
+ if (next != null) {
|
|
|
+ level = next.coerceIn(0, MAX_LEVEL)
|
|
|
+ muted = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ UPDATE_MUTE -> muted = ints?.firstOrNull() == 1
|
|
|
+ }
|
|
|
+ } catch (_: Throwable) {
|
|
|
+ // A malformed vendor callback must never take down the app.
|
|
|
+ }
|
|
|
+ reply?.writeNoException()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return super.onTransact(code, data, reply, flags)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private val connection = object : ServiceConnection {
|
|
|
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
|
+ handler.removeCallbacks(timeout)
|
|
|
+ if (service == null) {
|
|
|
+ status = "no-service"
|
|
|
+ return
|
|
|
+ }
|
|
|
+ val sound = try {
|
|
|
+ remoteModule(service)
|
|
|
+ } catch (error: Throwable) {
|
|
|
+ Log.i(TAG, "syu module lookup failed: $error")
|
|
|
+ null
|
|
|
+ }
|
|
|
+ if (sound == null) {
|
|
|
+ status = "no-module"
|
|
|
+ return
|
|
|
+ }
|
|
|
+ module = sound
|
|
|
+ connected = true
|
|
|
+ status = "ready"
|
|
|
+ try {
|
|
|
+ register(sound, UPDATE_VOLUME)
|
|
|
+ register(sound, UPDATE_MUTE)
|
|
|
+ } catch (error: Throwable) {
|
|
|
+ Log.i(TAG, "syu volume subscribe failed: $error")
|
|
|
+ }
|
|
|
+ Log.i(TAG, "syu sound module ready, level=$level")
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onServiceDisconnected(name: ComponentName?) {
|
|
|
+ // The binding is kept; Android reconnects when the system app is back.
|
|
|
+ module = null
|
|
|
+ connected = false
|
|
|
+ status = "disconnected"
|
|
|
+ Log.i(TAG, "syu sound module disconnected")
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onBindingDied(name: ComponentName?) {
|
|
|
+ module = null
|
|
|
+ connected = false
|
|
|
+ status = "died"
|
|
|
+ if (bound) {
|
|
|
+ try {
|
|
|
+ context.unbindService(this)
|
|
|
+ } catch (_: Throwable) {
|
|
|
+ // Already detached.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ bound = false
|
|
|
+ handler.postDelayed({ if (!disposed) start() }, RETRY_MS)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onNullBinding(name: ComponentName?) {
|
|
|
+ module = null
|
|
|
+ connected = false
|
|
|
+ status = "null-binding"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private val timeout = Runnable {
|
|
|
+ if (!connected) status = "timeout"
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Starts the binding once; safe to call from every attach. */
|
|
|
+ fun start() {
|
|
|
+ if (disposed || bound) return
|
|
|
+ status = "binding"
|
|
|
+ val intent = Intent(ACTION).setComponent(ComponentName(PACKAGE, SERVICE))
|
|
|
+ bound = try {
|
|
|
+ context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
|
+ } catch (error: Throwable) {
|
|
|
+ Log.i(TAG, "syu bind failed: $error")
|
|
|
+ false
|
|
|
+ }
|
|
|
+ if (!bound) {
|
|
|
+ status = "unavailable"
|
|
|
+ return
|
|
|
+ }
|
|
|
+ handler.postDelayed(timeout, CONNECT_TIMEOUT_MS)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Absolute master volume, 0..[MAX_LEVEL]. False when the bridge is down. */
|
|
|
+ fun setLevel(value: Int): Boolean {
|
|
|
+ val binder = module ?: return false
|
|
|
+ val target = value.coerceIn(0, MAX_LEVEL)
|
|
|
+ val data = Parcel.obtain()
|
|
|
+ val reply = Parcel.obtain()
|
|
|
+ return try {
|
|
|
+ data.writeInterfaceToken(MODULE_DESCRIPTOR)
|
|
|
+ data.writeInt(CMD_VOLUME)
|
|
|
+ data.writeIntArray(intArrayOf(target))
|
|
|
+ data.writeFloatArray(null)
|
|
|
+ data.writeStringArray(null)
|
|
|
+ binder.transact(1, data, reply, 0)
|
|
|
+ reply.readException()
|
|
|
+ // Do not optimistically record the value: the update callback is
|
|
|
+ // the only proof that the MCU actually applied this level.
|
|
|
+ true
|
|
|
+ } catch (error: Throwable) {
|
|
|
+ Log.i(TAG, "syu set $target failed: $error")
|
|
|
+ false
|
|
|
+ } finally {
|
|
|
+ data.recycle()
|
|
|
+ reply.recycle()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun dispose() {
|
|
|
+ disposed = true
|
|
|
+ handler.removeCallbacksAndMessages(null)
|
|
|
+ if (bound) {
|
|
|
+ try {
|
|
|
+ context.unbindService(connection)
|
|
|
+ } catch (_: Throwable) {
|
|
|
+ // Already detached.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ bound = false
|
|
|
+ module = null
|
|
|
+ connected = false
|
|
|
+ status = "off"
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Calls `IRemoteToolkit.getRemoteModule(int)` (transaction 1). */
|
|
|
+ private fun remoteModule(toolkit: IBinder): IBinder? {
|
|
|
+ val data = Parcel.obtain()
|
|
|
+ val reply = Parcel.obtain()
|
|
|
+ return try {
|
|
|
+ data.writeInterfaceToken(TOOLKIT_DESCRIPTOR)
|
|
|
+ data.writeInt(MODULE_SOUND)
|
|
|
+ if (!toolkit.transact(1, data, reply, 0)) return null
|
|
|
+ reply.readException()
|
|
|
+ reply.readStrongBinder()
|
|
|
+ } finally {
|
|
|
+ reply.recycle()
|
|
|
+ data.recycle()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Calls `IRemoteModule.register(IModuleCallback, int, int)` (transaction 3). */
|
|
|
+ private fun register(binder: IBinder, update: Int) {
|
|
|
+ val data = Parcel.obtain()
|
|
|
+ val reply = Parcel.obtain()
|
|
|
+ try {
|
|
|
+ data.writeInterfaceToken(MODULE_DESCRIPTOR)
|
|
|
+ data.writeStrongBinder(callback)
|
|
|
+ data.writeInt(update)
|
|
|
+ data.writeInt(1) // ask for the current value immediately
|
|
|
+ binder.transact(3, data, reply, 0)
|
|
|
+ reply.readException()
|
|
|
+ } finally {
|
|
|
+ data.recycle()
|
|
|
+ reply.recycle()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|