From 2fc97a70806e8494d37d73ec85ac5282ff60bcba Mon Sep 17 00:00:00 2001 From: Guy Dupont Date: Wed, 15 Jul 2026 16:56:04 -0400 Subject: [PATCH 1/9] allow key event forwarding to server, update emulator to demo, accompanying shared UI --- .../thelightphone/plugin/ManifestGenerator.kt | 1 + .../com/thelightphone/sdk/LightActivity.kt | 111 +++++++++++++--- .../com/thelightphone/sdk/LightScreen.kt | 19 ++- .../sdk/LightServiceConnection.kt | 61 +++++++-- .../com/thelightphone/sdk/LightViewModel.kt | 3 +- .../sdk/emulator/EmulatorApplication.kt | 46 ++++++- .../sdk/emulator/EmulatorDeviceKeyHandler.kt | 122 ++++++++++++++++++ .../sdk/emulator/EmulatorNavController.kt | 22 ++++ .../sdk/emulator/EmulatorSettings.kt | 119 +++++++++++++---- .../sdk/emulator/LightAudioManager.kt | 106 +++++++++++++++ .../sdk/emulator/MainActivity.kt | 105 +++++++++------ .../sdk/server/LightSdkServer.kt | 22 ++++ .../sdk/server/LightSdkService.kt | 15 ++- .../sdk/shared/LightDeviceKeys.kt | 11 ++ .../thelightphone/sdk/shared/LightResult.kt | 4 +- .../sdk/shared/LightServiceMethod.kt | 23 ++++ .../thelightphone/sdk/ui/LightKeyHandler.kt | 15 +++ .../thelightphone/sdk/ui/LightModalManager.kt | 95 ++++++++++++++ .../thelightphone/sdk/ui/LightProgressBar.kt | 103 +++++++++++++++ 19 files changed, 898 insertions(+), 105 deletions(-) create mode 100644 sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorDeviceKeyHandler.kt create mode 100644 sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorNavController.kt create mode 100644 sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/LightAudioManager.kt create mode 100644 sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightDeviceKeys.kt create mode 100644 sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightKeyHandler.kt create mode 100644 sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightModalManager.kt create mode 100644 sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightProgressBar.kt diff --git a/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt b/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt index 17757462..45ac8c03 100644 --- a/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt +++ b/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt @@ -39,6 +39,7 @@ object ManifestGenerator { appendLine(""" android:value="${xmlAttr(metadata.serverPackage)}" />""") appendLine(""" ( @@ -88,25 +95,30 @@ class LightActivity internal constructor() : ComponentActivity() { setContent { androidx.compose.runtime.LaunchedEffect(Unit) { contentReady = true } - val screen = currentScreen.value?.screen - if (screen != null) { - Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - val content: @Composable () -> Unit = { screen.Content() } - if (screen is ViewModelStoreOwner) { - CompositionLocalProvider( - LocalViewModelStoreOwner provides screen, - content = content, - ) - } else { - content() + Box(modifier = Modifier.fillMaxSize()) { + val screen = currentScreen.value?.screen + if (screen != null) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + val content: @Composable () -> Unit = { screen.Content() } + if (screen is ViewModelStoreOwner) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides screen, + content = content, + ) + } else { + content() + } } } } + // Transient modals draw on top of the current screen. + val activeModal by LightModalManager.activeModal.collectAsState() + activeModal?.Content() } } @@ -120,6 +132,69 @@ class LightActivity internal constructor() : ComponentActivity() { ) } + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + return super.onKeyDown(keyCode, event) + } + if (currentScreen.value?.screen?.onKeyDown(keyCode, event) == true) { + return true + } + forwardKeyEventToServer(LightServiceMethod.DeviceKeyEvent.EventType.KeyDown, keyCode, event) + return true + } + + override fun onKeyMultiple( + keyCode: Int, + repeatCount: Int, + event: KeyEvent? + ): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + return super.onKeyMultiple(keyCode, repeatCount, event) + } + if (event != null && currentScreen.value?.screen?.onKeyMultiple(keyCode, repeatCount, event) == true) { + return true + } + forwardKeyEventToServer(LightServiceMethod.DeviceKeyEvent.EventType.KeyMultiple, keyCode, event) + return true + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + return super.onKeyUp(keyCode, event) + } + if (event != null && currentScreen.value?.screen?.onKeyUp(keyCode, event) == true) { + return true + } + forwardKeyEventToServer(LightServiceMethod.DeviceKeyEvent.EventType.KeyUp, keyCode, event) + return true + } + + private fun forwardKeyEventToServer( + eventType: LightServiceMethod.DeviceKeyEvent.EventType, + keyCode: Int, + event: KeyEvent?, + ) { + val action = event?.action ?: when (eventType) { + LightServiceMethod.DeviceKeyEvent.EventType.KeyDown -> KeyEvent.ACTION_DOWN + LightServiceMethod.DeviceKeyEvent.EventType.KeyUp -> KeyEvent.ACTION_UP + LightServiceMethod.DeviceKeyEvent.EventType.KeyMultiple -> KeyEvent.ACTION_MULTIPLE + } + lifecycleScope.launch { + callRemoteServiceMethod( + LightServiceMethod.DeviceKeyEvent, + LightServiceMethod.DeviceKeyEvent.Request( + eventType = eventType, + keyCode = keyCode, + repeatCount = event?.repeatCount, + action = action, + characters = event?.characters, + unicodeChar = event?.unicodeChar ?: 0, + componentToRelaunch = componentName.flattenToString() + ) + ) + } + } + override fun onPause() { super.onPause() currentScreen.value?.screen?.notifyAppPause() diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightScreen.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightScreen.kt index 898f8197..020198d2 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightScreen.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightScreen.kt @@ -1,16 +1,15 @@ package com.thelightphone.sdk +import android.view.KeyEvent import androidx.compose.runtime.Composable -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner -import java.io.File -import kotlin.text.clear +import com.thelightphone.sdk.ui.LightKeyHandler -abstract class SimpleLightScreen(sealedActivity: SealedLightActivity) { +abstract class SimpleLightScreen(sealedActivity: SealedLightActivity) : + LightKeyHandler { internal val activity = sealedActivity.activity internal var result: ResultType? = null protected val lightContext = SealedLightContext(sealedActivity.activity) @@ -92,4 +91,14 @@ abstract class LightScreen>( super.goBack(result) } } + + override fun onKeyDown(keyCode: Int, event: KeyEvent) = viewModel.onKeyDown(keyCode, event) + + override fun onKeyUp(keyCode: Int, event: KeyEvent)= viewModel.onKeyUp(keyCode, event) + + override fun onKeyMultiple( + keyCode: Int, + repeatCount: Int, + event: KeyEvent + ) = viewModel.onKeyMultiple(keyCode, repeatCount, event) } diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightServiceConnection.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightServiceConnection.kt index c3feca9e..57b9ee3c 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightServiceConnection.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightServiceConnection.kt @@ -27,15 +27,21 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds private const val TAG = "LightServiceConnection" +private const val DEFAULT_TOKEN = "no_auth" internal object LightServiceConnection : ServiceConnection { private var serviceBinder: IBinder? = null private var bound = false private var binderReady = CompletableDeferred() - private var token: String? = null + private var token: String = DEFAULT_TOKEN + // Retained so we can rebind ourselves if the binding dies. applicationContext, so no leak. + private var appContext: Context? = null + private var serverPackage: String? = null fun bind(context: Context, serverPackage: String) { + appContext = context.applicationContext + this.serverPackage = serverPackage if (bound) return val intent = Intent(LightConstants.ACTION_BIND_SDK_SERVICE).apply { setPackage(serverPackage) @@ -46,12 +52,20 @@ internal object LightServiceConnection : ServiceConnection { } } + /** Forget the cached token so the next [ensureToken] re-authenticates with the server. */ + fun clearToken() { + token = DEFAULT_TOKEN + } + + private fun resetConnection() { + serviceBinder = null + clearToken() + binderReady = CompletableDeferred() + } + fun unbind(context: Context) { if (!bound) return - try { - context.unbindService(this) - } catch (_: IllegalArgumentException) { - } + runCatching { context.unbindService(this) } bound = false serviceBinder = null } @@ -91,6 +105,8 @@ internal object LightServiceConnection : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { Log.i(TAG, "Connected to LightSdkService") serviceBinder = binder + // force re-auth if re-connected + clearToken() if (binder != null) { binderReady.complete(binder) } @@ -98,15 +114,29 @@ internal object LightServiceConnection : ServiceConnection { override fun onServiceDisconnected(name: ComponentName?) { Log.w(TAG, "Disconnected from LightSdkService") - serviceBinder = null - token = null - binderReady = CompletableDeferred() + resetConnection() + } + + override fun onBindingDied(name: ComponentName?) { + Log.w(TAG, "Binding to LightSdkService died, rebinding") + resetConnection() + // BIND_AUTO_CREATE does not recover a dead binding on its own; unbind and rebind. + val ctx = appContext ?: return + val pkg = serverPackage ?: return + runCatching { ctx.unbindService(this) } + .onFailure { Log.w(TAG, "Failed to unbind service", it) } + bound = false + bind(ctx, pkg) + } + + override fun onNullBinding(name: ComponentName?) { + Log.e(TAG, "LightSdkService returned a null binding") } suspend fun awaitBinder(): IBinder = binderReady.await() fun ensureToken(): Boolean { - if (token != null) return true + if (token != DEFAULT_TOKEN) return true return when (val result = request( LightServiceMethod.GetToken.id, LightServiceMethod.GetToken.encodeRequest(Unit) @@ -138,7 +168,18 @@ suspend fun callRemoteServiceMethod( if (bound != true) { return@withContext LightResult.Error(LightResult.ErrorCode.Unknown, "Unable to bind to server") } - when (val result = LightServiceConnection.request(method.id, method.encodeRequest(body))) { + val encoded = method.encodeRequest(body) + var result = LightServiceConnection.request(method.id, encoded) + if (result is LightResult.Error && result.code == LightResult.ErrorCode.InvalidToken) { + // The server no longer recognizes our token (e.g. it restarted and lost its token + // store). Drop it, re-authenticate, and retry the request exactly once. GetToken is + // gated by caller verification rather than the token, so this cannot loop. + Log.i(TAG, "Token rejected calling remote method, re-auth + retry") + LightServiceConnection.clearToken() + LightServiceConnection.ensureToken() + result = LightServiceConnection.request(method.id, encoded) + } + when (result) { is LightResult.Success -> LightResult.Success(method.decodeResponse(result.data)) is LightResult.Error -> result } diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightViewModel.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightViewModel.kt index 1b7d2c6b..977944ac 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightViewModel.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightViewModel.kt @@ -1,8 +1,9 @@ package com.thelightphone.sdk import androidx.lifecycle.ViewModel +import com.thelightphone.sdk.ui.LightKeyHandler -abstract class LightViewModel : ViewModel() { +abstract class LightViewModel : ViewModel(), LightKeyHandler { open fun onScreenShow(screen: SimpleLightScreen) {} open fun onScreenHide(screen: SimpleLightScreen) {} open fun onAppPause() {} diff --git a/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorApplication.kt b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorApplication.kt index 07663c31..a1fb0cc8 100644 --- a/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorApplication.kt +++ b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorApplication.kt @@ -1,7 +1,9 @@ package com.thelightphone.sdk.emulator import android.app.Application +import android.content.ComponentName import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.content.pm.Signature import android.util.Log @@ -10,6 +12,8 @@ import com.thelightphone.sdk.server.ClientCertType import com.thelightphone.sdk.server.DefaultLightSdkServerSettings import com.thelightphone.sdk.server.LightSdkServer import com.thelightphone.sdk.shared.LightResult +import com.thelightphone.sdk.shared.LightServiceMethod +import com.thelightphone.sdk.ui.LightModalManager import java.security.MessageDigest // SHA-256 fingerprint of sdk/keys/lightsdk-dev.jks (alias: lightsdk-dev). @@ -17,10 +21,14 @@ private const val LIGHTSDK_DEV_CERT_SHA256 = "B9C33E29B0CCAD2BFF11ACAB55F65A3C517EF4BC92CD9C77785366FA353D5F28" class EmulatorApplication : Application() { + val lightAudioManager by lazy { LightAudioManager(this) } + val deviceKeyHandler by lazy { EmulatorDeviceKeyHandler(lightAudioManager) } + override fun onCreate() { super.onCreate() val mollySocketUriString = BuildConfig.MOLLYSOCKET_URI val settings = DefaultLightSdkServerSettings(this) + with(LightSdkServer) { registerLockReceiver(MainActivity::class.java, settings) customServiceMethodResolver = { callingId, methodId, payload -> @@ -41,10 +49,45 @@ class EmulatorApplication : Application() { } provideSdkSettings = { settings } permissionActivity = LightSdkPermissionActivity::class.java + rootActivity = MainActivity::class.java + onDeviceKeyEvent = { _, request -> handleDeviceKeyEvent(request) } } EmulatorHttpServer(this).start() } + + private fun handleDeviceKeyEvent(request: LightServiceMethod.DeviceKeyEvent.Request) { + deviceKeyHandler.onDeviceKeyEventRequest(request)?.let { modal -> + // when the server is done handling the key press, server should re-launch the component that was foregrounded + // at the time of key event + fun relaunchSender() { + val componentToRelaunch = + request.componentToRelaunch?.let(ComponentName::unflattenFromString) + startActivity( + Intent().setComponent(componentToRelaunch) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + EmulatorNavController.navigateTo(Nav.Toolbox) + } + + val keyModal = DeviceKeyModal( + modal, + onMoreClick = { + EmulatorNavController.navigateTo(Nav.Settings(backButtonOverride = ::relaunchSender)) + }, + onExpired = { relaunchSender() } + ) + // show the modal, and then pull this app into focus over the client + LightModalManager.show(keyModal) + foregroundApp() + } + } + + private fun foregroundApp() { + val intent = Intent(this.applicationContext, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } } // For the emulator, any apk built with LIGHTSDK_DEV_CERT_SHA256 is considered signed by Light @@ -63,7 +106,8 @@ private fun Context.checkLightSdkCert(callingPackage: String): ClientCertType { } val md = MessageDigest.getInstance("SHA-256") val matches = signers.any { sig -> - md.digest(sig.toByteArray()).toHexString().equals(LIGHTSDK_DEV_CERT_SHA256, ignoreCase = true) + md.digest(sig.toByteArray()).toHexString() + .equals(LIGHTSDK_DEV_CERT_SHA256, ignoreCase = true) } return if (matches) ClientCertType.LightSdkSignedUnverified else ClientCertType.Unknown } \ No newline at end of file diff --git a/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorDeviceKeyHandler.kt b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorDeviceKeyHandler.kt new file mode 100644 index 00000000..964c0a55 --- /dev/null +++ b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorDeviceKeyHandler.kt @@ -0,0 +1,122 @@ +package com.thelightphone.sdk.emulator + +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.thelightphone.sdk.shared.LightKeys +import com.thelightphone.sdk.shared.LightServiceMethod +import com.thelightphone.sdk.ui.LightIcon +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightModal +import com.thelightphone.sdk.ui.LightProgressBar +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.gridUnitsAsDp +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.CompletableDeferred + +class EmulatorDeviceKeyHandler(private val audioManager: LightAudioManager) { + + companion object { + private const val TAG = "EmulatorDeviceKeyHandler" + } + + fun onDeviceKeyEventRequest(request: LightServiceMethod.DeviceKeyEvent.Request): AudioModal? { + if (request.eventType != LightServiceMethod.DeviceKeyEvent.EventType.KeyDown) return null + return when (request.keyCode) { + LightKeys.VOLUME_UP -> audioManager.stepUp() + LightKeys.VOLUME_DOWN -> audioManager.stepDown() + else -> { + Log.d(TAG, "Unhandled keyCode: ${request.keyCode}") + null + } + } + } +} + +class DeviceKeyModal( + private val audioModal: AudioModal, + private val onMoreClick: () -> Unit, + override val onExpired: () -> Unit +) : LightModal { + private val dismissDeferred = CompletableDeferred() + + override fun dismiss() { dismissDeferred.complete(Unit) } + override suspend fun awaitDismiss() { dismissDeferred.await() } + + @Composable + override fun Content() { + val themeColors by LightThemeController.colors.collectAsState() + val label = when (audioModal.type) { + ModalType.RingerVol -> "ringer" + ModalType.CallVol -> "call volume" + ModalType.AlarmVol -> "alarm" + ModalType.MediaVol -> "volume" + ModalType.Silent -> "silent" + ModalType.VibrateOnly -> "vibrate only" + } + LightTheme(themeColors) { + Surface { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 3.5f.gridUnitsAsDp()) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Spacer(Modifier.height(10.5f.gridUnitsAsDp())) + LightText(label, variant = LightTextVariant.Subtitle) + if (audioModal.type == ModalType.Silent) { + Spacer(Modifier.height(2f.gridUnitsAsDp())) + LightIcon(LightIcons.SPEAKER_MUTED) + } else { + Spacer(Modifier.height(2.5f.gridUnitsAsDp())) + LightProgressBar(themeColors, audioModal.value) + } + } + LightText( + text = ". . .", + variant = LightTextVariant.Copy, + modifier = Modifier.lightClickable { + dismiss() + onMoreClick() + } + ) + Spacer(Modifier.height(1f.gridUnitsAsDp())) + } + } + } + } +} + +@Preview(widthDp = 1080 / 3, heightDp = 1240 / 3, showBackground = true) +@Composable +fun DeviceKeyModalPreview() { + var audioModal by remember { mutableStateOf(AudioModal(ModalType.RingerVol, 0.4f)) } + fun toggle() { + audioModal = if (audioModal.type == ModalType.RingerVol) { + AudioModal(ModalType.Silent, 0.0f) + } else { + AudioModal(ModalType.RingerVol, 0.4f) + } + } + DeviceKeyModal(audioModal, onExpired = {}, onMoreClick = ::toggle).Content() +} diff --git a/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorNavController.kt b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorNavController.kt new file mode 100644 index 00000000..19e2cc16 --- /dev/null +++ b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorNavController.kt @@ -0,0 +1,22 @@ +package com.thelightphone.sdk.emulator + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed interface Nav { + data object LockScreen : Nav + data object Toolbox : Nav + class Settings( + val startingScreen: EmulatorSettingsNav = EmulatorSettingsNav.Root, + val backButtonOverride: (() -> Unit)? = null + ) : Nav +} + +object EmulatorNavController { + private val _currentNav = MutableStateFlow