diff --git a/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoHomeScreen.kt b/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoHomeScreen.kt index 909f6701..72f12260 100644 --- a/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoHomeScreen.kt +++ b/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoHomeScreen.kt @@ -17,6 +17,7 @@ import com.thelightphone.sdk.InitialScreen import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen import com.thelightphone.sdk.ui.LightFullscreenModal +import com.thelightphone.sdk.ui.LightScrollView import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme @@ -47,8 +48,9 @@ class UiDemoHomeScreen(sealedActivity: SealedLightActivity) : modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), ) - Column( + LightScrollView( modifier = Modifier + .weight(1f) .fillMaxWidth() .padding(horizontal = 1f.gridUnitsAsDp()), ) { @@ -113,6 +115,20 @@ class UiDemoHomeScreen(sealedActivity: SealedLightActivity) : .lightClickable { LightThemeController.toggle() } .padding(vertical = 0.75f.gridUnitsAsDp()), ) + LightText( + text = "PROGRESS BAR", + variant = LightTextVariant.Copy, + modifier = Modifier + .lightClickable { navigateTo(::UiDemoProgressBarScreen) } + .padding(vertical = 0.75f.gridUnitsAsDp()), + ) + LightText( + text = "KEY EVENTS", + variant = LightTextVariant.Copy, + modifier = Modifier + .lightClickable { navigateTo(::UiDemoKeyEventsScreen) } + .padding(vertical = 0.75f.gridUnitsAsDp()), + ) } } diff --git a/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoKeyEventsScreen.kt b/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoKeyEventsScreen.kt new file mode 100644 index 00000000..e7d2b208 --- /dev/null +++ b/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoKeyEventsScreen.kt @@ -0,0 +1,110 @@ +package com.thelightphone.uidemo + +import android.view.KeyEvent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightScrollView +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.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +import com.thelightphone.sdk.ui.gridUnitsAsDp + +private const val MAX_EVENTS = 20 + +class UiDemoKeyEventsScreen(sealedActivity: SealedLightActivity) : + SimpleLightScreen(sealedActivity) { + + // Newest-first, capped. Mutated from key dispatch (main thread), read by Content(). + private val events = mutableStateListOf() + + private fun record(type: String, keyCode: Int, event: KeyEvent) { + events.add(0, "${KeyEvent.keyCodeToString(keyCode)} ($keyCode) $type " + + "action=${event.action} repeat=${event.repeatCount}") + if (events.size > MAX_EVENTS) events.removeRange(MAX_EVENTS, events.size) + } + + // Return true to consume: keeps this screen self-contained (no server forwarding). + // BACK/HOME never arrive here — LightActivity short-circuits them before the screen. + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + record("DOWN", keyCode, event) + return true + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + record("UP", keyCode, event) + return true + } + + override fun onKeyMultiple(keyCode: Int, repeatCount: Int, event: KeyEvent): Boolean { + record("MULTIPLE", keyCode, event) + return true + } + + @Composable + override fun Content() = ContentInner(events, onBack = { goBack() }) +} + +@Composable +private fun ContentInner(events: List, onBack: () -> Unit) { + val themeColors by LightThemeController.colors.collectAsState() + LightTheme(colors = themeColors) { + Column(modifier = Modifier.fillMaxSize()) { + LightTopBar( + leftButton = LightBarButton.LightIcon( + icon = LightIcons.BACK, + onClick = onBack, + ), + center = LightTopBarCenter.Text("Key Events"), + modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), + ) + if (events.isEmpty()) { + LightText( + text = "Press a hardware key…", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(horizontal = 1f.gridUnitsAsDp()), + ) + } + LightScrollView( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 1f.gridUnitsAsDp()), + ) { + events.forEach { line -> + LightText( + text = line, + variant = LightTextVariant.Superfine, + modifier = Modifier.padding(vertical = 0.5f.gridUnitsAsDp()), + ) + } + } + } + } +} + +@Preview(widthDp = 1080 / 3, heightDp = 1240 / 3, showBackground = true) +@Composable +fun UiDemoKeyEventsScreenPreview() { + ContentInner( + events = listOf( + "DOWN KEYCODE_VOLUME_UP (24) action=0 repeat=0", + "UP KEYCODE_VOLUME_UP (24) action=1 repeat=0", + ), + onBack = {}, + ) +} diff --git a/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoProgressBarScreen.kt b/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoProgressBarScreen.kt new file mode 100644 index 00000000..14b16aeb --- /dev/null +++ b/examples/ui-demo/src/main/kotlin/com/thelightphone/uidemo/UiDemoProgressBarScreen.kt @@ -0,0 +1,67 @@ +package com.thelightphone.uidemo + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons +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.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +import com.thelightphone.sdk.ui.LightTouchableProgressBar +import com.thelightphone.sdk.ui.gridUnitsAsDp +import java.util.Locale + +class UiDemoProgressBarScreen(sealedActivity: SealedLightActivity) : + SimpleLightScreen(sealedActivity) { + + @Composable + override fun Content() = ContentInner(onBack = { goBack() }) +} + +@Composable +private fun ContentInner(onBack: () -> Unit) { + val themeColors by LightThemeController.colors.collectAsState() + var progress by remember { mutableStateOf(0.5f) } + LightTheme(colors = themeColors) { + Column(modifier = Modifier.fillMaxSize()) { + LightTopBar( + leftButton = LightBarButton.LightIcon( + icon = LightIcons.BACK, + onClick = onBack, + ), + center = LightTopBarCenter.Text("Progress Bar"), + modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), + ) + Column( + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(2f.gridUnitsAsDp()) + ) { + LightText("%.2f".format(Locale.ROOT, progress), variant = LightTextVariant.Copy) + LightTouchableProgressBar(themeColors, progress, onValueChange = { progress = it }) + } + } + } +} + +@Preview(widthDp = 1080 / 3, heightDp = 1240 / 3, showBackground = true) +@Composable +fun UiDemoProgressBarScreen() { + ContentInner(onBack = {}) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39e5439f..5178687d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -light-keyboard = { module = "com.thelightphone.lp3keyboard:ui", version = "0.0.11"} +light-keyboard = { module = "com.thelightphone.lp3keyboard:ui", version = "0.0.16"} androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } diff --git a/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt b/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt index 17757462..e9af9d08 100644 --- a/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt +++ b/plugin/src/main/kotlin/com/thelightphone/plugin/ManifestGenerator.kt @@ -29,44 +29,48 @@ object ManifestGenerator { for (feature in features) { appendLine(""" """) } - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine(""" """) - appendLine("""""") + val screenOrientation = metadata.orientation?.let { + "\n | android:screenOrientation=\"${xmlAttr(it)}\"" + }.orEmpty() + appendLine( + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + |""".trimMargin() + ) } private fun xmlAttr(value: String): String = buildString(value.length) { diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightActivity.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightActivity.kt index b8411bd7..f46ece93 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightActivity.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightActivity.kt @@ -2,6 +2,7 @@ package com.thelightphone.sdk import android.content.Context import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback @@ -10,12 +11,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -23,12 +23,31 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.thelightphone.lp3Keyboard.ui.LightDeviceKeys +import com.thelightphone.sdk.shared.LightServiceMethod +import com.thelightphone.sdk.ui.LightModalManager +import kotlinx.coroutines.launch import java.io.File +private class ScreenViewModelStoreOwner : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() +} + private class BackStackEntry( val screen: SimpleLightScreen, val callback: ((T) -> Unit)? = null, ) { + // SimpleLightScreens that aren't already a ViewModelStoreOwner (LightScreen) still need a + // store scoped to this specific navigation instance, so composables inside them that call + // viewModel() don't resolve against the long-lived Activity store and get a stale ViewModel + // handed back on a later, unrelated navigation to a screen with the same viewModel() key. + val viewModelStoreOwner: ViewModelStoreOwner = + screen as? ViewModelStoreOwner ?: ScreenViewModelStoreOwner() + fun deliverResult() { val result = screen.result ?: return callback?.invoke(result) @@ -55,6 +74,7 @@ class LightActivity internal constructor() : ComponentActivity() { val popped = current.screen popped.notifyWillHide() popped.destroy() + current.viewModelStoreOwner.viewModelStore.clear() backStack.removeAt(backStack.lastIndex) if (backStack.isEmpty()) { finish() @@ -88,25 +108,25 @@ 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) { + Box(modifier = Modifier.fillMaxSize()) { + val entry = currentScreen.value + if (entry != null) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { CompositionLocalProvider( - LocalViewModelStoreOwner provides screen, - content = content, + LocalViewModelStoreOwner provides entry.viewModelStoreOwner, + content = { entry.screen.Content() }, ) - } else { - content() } } } + // Transient modals draw on top of the current screen. + val activeModal by LightModalManager.activeModal.collectAsState() + activeModal?.Content() } } @@ -120,6 +140,75 @@ class LightActivity internal constructor() : ComponentActivity() { ) } + private val Int.isSystemKeyCode: Boolean + get() = (this == KeyEvent.KEYCODE_BACK || this == KeyEvent.KEYCODE_HOME) + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // don't do anything with android keys + // back button override handled elsewhere and home button won't get dispatched to external tools + return if (keyCode.isSystemKeyCode) { + super.onKeyDown(keyCode, event) + } else if (currentScreen.value?.screen?.onKeyDown(keyCode, event) == true) { + // let the active screen handle the button if it wants to + true + } else if (LightDeviceKeys.mapping.containsKey(keyCode)) { + // otherwise see if the server wants to use it + forwardKeyEventToServer(keyCode, event) + true + } else { + false + } + } + + override fun onKeyMultiple( + keyCode: Int, + repeatCount: Int, + event: KeyEvent + ): Boolean { + return if (keyCode.isSystemKeyCode) { + super.onKeyMultiple(keyCode, repeatCount, event) + } else if (currentScreen.value?.screen?.onKeyMultiple(keyCode, repeatCount, event) == true) { + true + } else if (LightDeviceKeys.mapping.containsKey(keyCode)) { + forwardKeyEventToServer(keyCode, event) + true + } else { + false + } + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + return if (keyCode.isSystemKeyCode) { + super.onKeyUp(keyCode, event) + } else if (currentScreen.value?.screen?.onKeyUp(keyCode, event) == true) { + true + } else if (LightDeviceKeys.mapping.containsKey(keyCode)) { + forwardKeyEventToServer(keyCode, event) + true + } else { + false + } + } + + private fun forwardKeyEventToServer( + keyCode: Int, + event: KeyEvent, + ) { + lifecycleScope.launch { + callRemoteServiceMethod( + LightServiceMethod.DeviceKeyEvent, + LightServiceMethod.DeviceKeyEvent.Request( + keyCode = keyCode, + repeatCount = event.repeatCount, + action = event.action, + characters = event.characters, + unicodeChar = event.unicodeChar, + componentToRelaunch = componentName.flattenToString() + ) + ) + } + } + override fun onPause() { super.onPause() currentScreen.value?.screen?.notifyAppPause() diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightKeyboardManager.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightKeyboardManager.kt index 5338b1ef..54ac5018 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightKeyboardManager.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/LightKeyboardManager.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.thelightphone.lp3Keyboard.ui.KeyboardOptions -import com.thelightphone.lp3Keyboard.ui.defaultEmojis import com.thelightphone.lp3Keyboard.ui.parseEmojiString +import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis import com.thelightphone.sdk.shared.LightServiceMethod import com.thelightphone.sdk.shared.error import com.thelightphone.sdk.shared.getOrNull @@ -40,7 +40,8 @@ suspend fun refreshKeyboardOptions(): KeyboardOptions? { // not using currently - may want to move into LayoutOptions in Lp3Keyboard source displayReturn = true, displayVoice = result.displayVoice, - enableKeyAnimation = result.enableKeyAnimation + enableKeyAnimation = result.enableKeyAnimation, + swipeEnabled = result.swipeEnabled == true // nullable ) } 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..d4f57503 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, token is likely invalid server-side now + clearToken() if (binder != null) { binderReady.complete(binder) } @@ -98,15 +114,28 @@ 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() + 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 +167,16 @@ 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) { + // Case where the app is "allowed", but the token is no longer valid, allow one retry + 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..3129314e 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,44 @@ class EmulatorApplication : Application() { } provideSdkSettings = { settings } permissionActivity = LightSdkPermissionActivity::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-foreground + // the app that sent it, if desired + 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) + foregroundEmulator() + } + } + + private fun foregroundEmulator() { + 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 +105,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..448ea5f1 --- /dev/null +++ b/sdk/emulator/src/main/kotlin/com/thelightphone/sdk/emulator/EmulatorDeviceKeyHandler.kt @@ -0,0 +1,125 @@ +package com.thelightphone.sdk.emulator + +import android.util.Log +import android.view.KeyEvent.ACTION_DOWN +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.lp3Keyboard.ui.LightDeviceKeys +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.action != ACTION_DOWN) return null + return when (LightDeviceKeys.mapping[request.keyCode]) { + LightDeviceKeys.VolumeUp -> audioManager.stepUp() + LightDeviceKeys.VolumeDown -> audioManager.stepDown() + else -> { + Log.d(TAG, "Unhandled keyCode: ${request.keyCode}") + null + } + } + } +} + +// Modeled after LightOS volume rocker modal +// In ideal world, this will be used directly in LightOS +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