From 8280126e513c60874c874c04119be40777a3a030 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Sat, 30 May 2026 19:01:22 -0500 Subject: [PATCH] Finish replay implementation --- android/app/build.gradle.kts | 4 +- .../android/LibraryRecentsMultiItemTest.kt | 109 +++++++++ .../hawkeye/android/ShellNavigationTest.kt | 84 +++++++ android/app/src/main/AndroidManifest.xml | 51 +--- android/app/src/main/cpp/CMakeLists.txt | 1 + android/app/src/main/cpp/android_main.c | 22 +- android/app/src/main/cpp/replay_control.h | 38 +++ android/app/src/main/cpp/replay_jni.c | 75 ++++++ .../px4/hawkeye/android/HawkeyeActivity.kt | 189 ++++++++------- .../com/px4/hawkeye/android/HawkeyeApp.kt | 4 + .../hawkeye/android/IntentRouterActivity.kt | 140 ----------- .../com/px4/hawkeye/android/di/AppModule.kt | 5 +- .../android/render/NativeReplayController.kt | 55 +++++ .../hawkeye/android/render/RenderSession.kt | 36 --- .../android/render/RendererLauncher.kt | 12 +- .../render/transport/TransportAction.kt | 8 + .../render/transport/TransportScreen.kt | 129 ++++++++++ .../render/transport/TransportState.kt | 12 + .../render/transport/TransportViewModel.kt | 65 +++++ .../replay/AndroidReplayPlaybackLauncher.kt | 17 ++ .../com/px4/hawkeye/android/shell/AppShell.kt | 18 +- android/app/src/main/res/values/strings.xml | 6 +- android/app/src/main/res/values/themes.xml | 9 - .../android/render/RenderSessionTest.kt | 46 ---- .../render/transport/FakeReplayController.kt | 16 ++ .../transport/TransportViewModelTest.kt | 88 +++++++ android/build.gradle.kts | 1 + android/core/domain/build.gradle.kts | 5 + .../px4/hawkeye/core/domain/LibraryEntry.kt | 17 ++ .../core/domain/ReplayLibraryRepository.kt | 29 +++ .../presentation/ReplayPlaybackLauncher.kt | 14 ++ .../feature/home/presentation/HomeAction.kt | 1 + .../feature/home/presentation/HomeEvent.kt | 7 + .../feature/home/presentation/HomeScreen.kt | 81 ++++++- .../feature/home/presentation/HomeState.kt | 14 +- .../home/presentation/HomeViewModel.kt | 31 ++- .../home/presentation/RecentFlightUiMapper.kt | 16 ++ .../src/main/res/values/strings.xml | 1 + .../FakeReplayLibraryRepository.kt | 27 +++ .../home/presentation/HomeViewModelTest.kt | 48 +++- android/feature/replay/data/build.gradle.kts | 11 +- .../1.json | 55 +++++ .../replay/data/AndroidReplayFileManager.kt | 50 ++++ .../replay/data/AndroidUlogInboxDataSource.kt | 113 --------- .../replay/data/LibraryEntryMappers.kt | 19 ++ .../feature/replay/data/LibraryFileStore.kt | 74 ++++++ .../feature/replay/data/ReplayFileManager.kt | 24 ++ .../data/RoomReplayLibraryRepository.kt | 85 +++++++ .../replay/data/db/LibraryEntryEntity.kt | 19 ++ .../feature/replay/data/db/ReplayDatabase.kt | 9 + .../replay/data/db/ReplayLibraryDao.kt | 23 ++ .../replay/data/di/ReplayDataModule.kt | 24 +- .../replay/data/FakeReplayFileManager.kt | 29 +++ .../replay/data/FakeReplayLibraryDao.kt | 29 +++ .../replay/data/LibraryFileStoreTest.kt | 76 ++++++ .../data/RoomReplayLibraryRepositoryTest.kt | 129 ++++++++++ .../feature/replay/domain/build.gradle.kts | 7 - .../feature/replay/domain/ReplayError.kt | 9 - .../hawkeye/feature/replay/domain/UlogFile.kt | 6 - .../replay/domain/UlogInboxDataSource.kt | 25 -- .../feature/replay/domain/UlogPreview.kt | 6 - .../replay/presentation/build.gradle.kts | 2 +- .../presentation/LibraryEntryUiMapper.kt | 35 +++ .../replay/presentation/ReplayAction.kt | 24 -- .../presentation/ReplayErrorToUiText.kt | 10 - .../replay/presentation/ReplayEvent.kt | 7 - .../presentation/ReplayLibraryAction.kt | 10 + .../replay/presentation/ReplayLibraryEvent.kt | 13 + .../presentation/ReplayLibraryScreen.kt | 225 ++++++++++++++++++ .../replay/presentation/ReplayLibraryState.kt | 19 ++ .../presentation/ReplayLibraryViewModel.kt | 77 ++++++ .../replay/presentation/ReplayScreen.kt | 114 --------- .../replay/presentation/ReplayState.kt | 15 -- .../replay/presentation/ReplayViewModel.kt | 107 --------- .../di/ReplayPresentationModule.kt | 11 +- .../src/main/res/values/strings.xml | 27 +-- .../FakeReplayLibraryRepository.kt | 42 ++++ .../presentation/FakeUlogInboxDataSource.kt | 36 --- .../presentation/LibraryEntryUiMapperTest.kt | 28 +++ .../ReplayLibraryViewModelTest.kt | 151 ++++++++++++ .../presentation/ReplayViewModelTest.kt | 130 ---------- android/gradle.properties | 6 +- android/gradle/libs.versions.toml | 16 +- android/settings.gradle.kts | 1 - src/hud.c | 8 +- src/hud.h | 1 + 86 files changed, 2432 insertions(+), 1036 deletions(-) create mode 100644 android/app/src/androidTest/java/com/px4/hawkeye/android/LibraryRecentsMultiItemTest.kt create mode 100644 android/app/src/androidTest/java/com/px4/hawkeye/android/ShellNavigationTest.kt create mode 100644 android/app/src/main/cpp/replay_control.h create mode 100644 android/app/src/main/cpp/replay_jni.c delete mode 100644 android/app/src/main/java/com/px4/hawkeye/android/IntentRouterActivity.kt create mode 100644 android/app/src/main/java/com/px4/hawkeye/android/render/NativeReplayController.kt delete mode 100644 android/app/src/main/java/com/px4/hawkeye/android/render/RenderSession.kt create mode 100644 android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportAction.kt create mode 100644 android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportScreen.kt create mode 100644 android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportState.kt create mode 100644 android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportViewModel.kt create mode 100644 android/app/src/main/java/com/px4/hawkeye/android/replay/AndroidReplayPlaybackLauncher.kt delete mode 100644 android/app/src/test/java/com/px4/hawkeye/android/render/RenderSessionTest.kt create mode 100644 android/app/src/test/java/com/px4/hawkeye/android/render/transport/FakeReplayController.kt create mode 100644 android/app/src/test/java/com/px4/hawkeye/android/render/transport/TransportViewModelTest.kt create mode 100644 android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/LibraryEntry.kt create mode 100644 android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/ReplayLibraryRepository.kt create mode 100644 android/core/presentation/src/main/kotlin/com/px4/hawkeye/core/presentation/ReplayPlaybackLauncher.kt create mode 100644 android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/RecentFlightUiMapper.kt create mode 100644 android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/FakeReplayLibraryRepository.kt create mode 100644 android/feature/replay/data/schemas/com.px4.hawkeye.feature.replay.data.db.ReplayDatabase/1.json create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidReplayFileManager.kt delete mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidUlogInboxDataSource.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryEntryMappers.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStore.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/ReplayFileManager.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepository.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/LibraryEntryEntity.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayDatabase.kt create mode 100644 android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayLibraryDao.kt create mode 100644 android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayFileManager.kt create mode 100644 android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayLibraryDao.kt create mode 100644 android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStoreTest.kt create mode 100644 android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepositoryTest.kt delete mode 100644 android/feature/replay/domain/build.gradle.kts delete mode 100644 android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/ReplayError.kt delete mode 100644 android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogFile.kt delete mode 100644 android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogInboxDataSource.kt delete mode 100644 android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogPreview.kt create mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapper.kt delete mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayAction.kt delete mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayErrorToUiText.kt delete mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayEvent.kt create mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryAction.kt create mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryEvent.kt create mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryScreen.kt create mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryState.kt create mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModel.kt delete mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayScreen.kt delete mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayState.kt delete mode 100644 android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModel.kt create mode 100644 android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeReplayLibraryRepository.kt delete mode 100644 android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeUlogInboxDataSource.kt create mode 100644 android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapperTest.kt create mode 100644 android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModelTest.kt delete mode 100644 android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModelTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f93f06..4a4f297 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -51,7 +51,6 @@ dependencies { implementation(project(":core:domain")) implementation(project(":core:presentation")) implementation(project(":core:design-system")) - implementation(project(":feature:replay:domain")) implementation(project(":feature:replay:data")) implementation(project(":feature:replay:presentation")) implementation(project(":core:navigation")) @@ -80,6 +79,9 @@ dependencies { testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.assertk) + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/android/app/src/androidTest/java/com/px4/hawkeye/android/LibraryRecentsMultiItemTest.kt b/android/app/src/androidTest/java/com/px4/hawkeye/android/LibraryRecentsMultiItemTest.kt new file mode 100644 index 0000000..bf5117b --- /dev/null +++ b/android/app/src/androidTest/java/com/px4/hawkeye/android/LibraryRecentsMultiItemTest.kt @@ -0,0 +1,109 @@ +package com.px4.hawkeye.android + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity +import com.px4.hawkeye.feature.replay.data.db.ReplayLibraryDao +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext + +/** + * Seeds several library entries directly into the real Room DAO (exercising the live + * `ORDER BY imported_at_millis DESC` query), then verifies multi-item behavior on a + * device: the Home peek shows only the three newest while the Replay library shows every + * entry, and the library keeps its items across rotation. + * + * Uses an empty compose rule so seeding happens before the activity launches, making the + * first library emission deterministic. The DAO is resolved from the app's Koin graph. + */ +@RunWith(AndroidJUnit4::class) +class LibraryRecentsMultiItemTest { + + @get:Rule + val composeRule = createEmptyComposeRule() + + private val dao: ReplayLibraryDao get() = GlobalContext.get().get() + + @Before + fun seed() = runBlocking { + clearLibrary() + SEED.forEach { dao.insert(it) } + } + + @After + fun cleanup() = runBlocking { clearLibrary() } + + private suspend fun clearLibrary() { + dao.observeAll().first().forEach { dao.deleteById(it.id) } + } + + @Test + fun homeShowsTheThreeNewestRecents() { + ActivityScenario.launch(MainActivity::class.java).use { + awaitText(NEWEST) + composeRule.onNodeWithText(NEWEST).assertIsDisplayed() + composeRule.onNodeWithText(SECOND).assertIsDisplayed() + composeRule.onNodeWithText(THIRD).assertIsDisplayed() + // The fourth (oldest) entry is beyond the three-item Home peek. + composeRule.onNodeWithText(OLDEST).assertDoesNotExist() + } + } + + @Test + fun replayLibraryShowsEveryItem() { + ActivityScenario.launch(MainActivity::class.java).use { + composeRule.onNodeWithText(HOME_REPLAY_CARD).performClick() + awaitText(OLDEST) + listOf(NEWEST, SECOND, THIRD, OLDEST).forEach { + composeRule.onNodeWithText(it).assertIsDisplayed() + } + } + } + + @Test + fun replayLibraryKeepsItemsAcrossRotation() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + composeRule.onNodeWithText(HOME_REPLAY_CARD).performClick() + awaitText(OLDEST) + + scenario.recreate() + composeRule.waitForIdle() + + awaitText(OLDEST) + composeRule.onNodeWithText(NEWEST).assertIsDisplayed() + } + } + + private fun awaitText(text: String) { + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + + private companion object { + const val NEWEST = "newest.ulg" + const val SECOND = "second.ulg" + const val THIRD = "third.ulg" + const val OLDEST = "oldest.ulg" + const val HOME_REPLAY_CARD = "Replay a flight" + + // Same payload, different names/timestamps. Newest first by imported-at millis. + val SEED = listOf( + LibraryEntryEntity("e1", NEWEST, 1024L, 40L, "e1.ulg"), + LibraryEntryEntity("e2", SECOND, 1024L, 30L, "e2.ulg"), + LibraryEntryEntity("e3", THIRD, 1024L, 20L, "e3.ulg"), + LibraryEntryEntity("e4", OLDEST, 1024L, 10L, "e4.ulg"), + ) + } +} diff --git a/android/app/src/androidTest/java/com/px4/hawkeye/android/ShellNavigationTest.kt b/android/app/src/androidTest/java/com/px4/hawkeye/android/ShellNavigationTest.kt new file mode 100644 index 0000000..dd85f66 --- /dev/null +++ b/android/app/src/androidTest/java/com/px4/hawkeye/android/ShellNavigationTest.kt @@ -0,0 +1,84 @@ +package com.px4.hawkeye.android + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives the Compose shell on a device: navigates to every destination and rotates each + * one. Rotation is exercised with [androidx.test.core.app.ActivityScenario.recreate], + * which runs the same activity destroy/recreate + state-restoration path a real + * orientation change triggers. Each screen must survive it (no crash, content intact), + * and the selected destination must be preserved (it lives in ShellViewModel). + * + * The native renderer (HawkeyeActivity) is intentionally out of scope here: it runs in a + * separate `:renderer` process, is landscape-locked, and hard-exits on teardown. + */ +@RunWith(AndroidJUnit4::class) +class ShellNavigationTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun rotate() { + composeRule.activityRule.scenario.recreate() + composeRule.waitForIdle() + } + + @Test + fun home_isShown_andSurvivesRotation() { + composeRule.onNodeWithText(HOME_REPLAY_CARD).assertIsDisplayed() + rotate() + composeRule.onNodeWithText(HOME_REPLAY_CARD).assertIsDisplayed() + } + + @Test + fun replayLibrary_isReachableFromHome_andSurvivesRotation() { + composeRule.onNodeWithText(HOME_REPLAY_CARD).performClick() + composeRule.onNodeWithText(REPLAY_LIBRARY_TITLE).assertIsDisplayed() + rotate() + composeRule.onNodeWithText(REPLAY_LIBRARY_TITLE).assertIsDisplayed() + } + + @Test + fun settings_isReachableFromNavBar_andSurvivesRotation() { + composeRule.onNodeWithText(NAV_SETTINGS).performClick() + composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed() + rotate() + composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed() + } + + @Test + fun live_isReachableFromHome_andSurvivesRotation() { + composeRule.onNodeWithText(HOME_CONNECT_CARD).performClick() + composeRule.onNodeWithText(LIVE_COMING_SOON).assertIsDisplayed() + rotate() + composeRule.onNodeWithText(LIVE_COMING_SOON).assertIsDisplayed() + } + + @Test + fun selectedDestination_isPreservedAcrossRotation() { + composeRule.onNodeWithText(NAV_SETTINGS).performClick() + composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed() + + rotate() + + // Still on Settings — not reset to Home — proving the back stack in ShellViewModel + // survived the config change. + composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed() + } + + private companion object { + const val HOME_REPLAY_CARD = "Replay a flight" + const val HOME_CONNECT_CARD = "Connect to a simulator" + const val REPLAY_LIBRARY_TITLE = "Replay library" + const val NAV_SETTINGS = "Settings" + const val SETTINGS_THEME_HEADER = "Theme" + const val LIVE_COMING_SOON = "Connecting to a simulator is coming in Plan 3." + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2908f8b..a5403bf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ @@ -36,56 +37,6 @@ android:value="hawkeye" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 4c3b1cf..49e9d1b 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -22,6 +22,7 @@ set(HAWKEYE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../src") add_library(hawkeye SHARED android_main.c + replay_jni.c ${HAWKEYE_SRC}/scene.c ${HAWKEYE_SRC}/vehicle.c ${HAWKEYE_SRC}/theme.c diff --git a/android/app/src/main/cpp/android_main.c b/android/app/src/main/cpp/android_main.c index f551eec..04d36bd 100644 --- a/android/app/src/main/cpp/android_main.c +++ b/android/app/src/main/cpp/android_main.c @@ -5,6 +5,7 @@ #include "vehicle.h" #include "data_source.h" #include "hud.h" +#include "replay_control.h" #include #include #include @@ -359,10 +360,10 @@ static void handle_touch(Camera3D *cam, Vector3 *orbit_target, } } -// HawkeyeActivity (Kotlin) writes inbound .ulg files via .tmp + atomic rename to +// The library repository (Kotlin) stages a .ulg via .tmp + atomic rename to // inbox/current.ulg, then writes a fresh millis token into inbox/.ready. We poll // the sentinel's *contents* (not stat-mtime — f2fs has 1-second granularity and -// would coalesce two shares in the same wall second) once per second and reload +// would coalesce two stages in the same wall second) once per second and reload // the replay when the token changes. static data_source_t g_ds; static bool g_ds_active = false; @@ -391,7 +392,7 @@ static long long read_ready_token(const char *path) { } static int try_load_inbox_ulog(vehicle_t *vehicle) { - // Throttle to 1 Hz. Sentinel changes are user-driven (intent shares), so + // Throttle to 1 Hz. Sentinel changes are user-driven (library playback), so // a per-frame stat()/read() at 60 Hz is wasted work — and since we read // the sentinel's contents (not its mtime), an up-to-1-second detection // delay is the only cost. @@ -415,7 +416,7 @@ static int try_load_inbox_ulog(vehicle_t *vehicle) { __android_log_print(ANDROID_LOG_ERROR, "Hawkeye", "data_source_ulog_create(%s) failed: %d", ulg, rc); // Mark the bad token consumed so we don't retry every second; the - // user must re-share to trigger another attempt. + // user must re-open the log to trigger another attempt. g_last_ready_token = token; return 0; } @@ -493,6 +494,9 @@ int main(int argc, char *argv[]) { vehicle_init(&vehicle, MODEL_QUADROTOR, scene.lighting_shader); hud_init(&g_hud); + // The transport bar is driven by a Compose overlay on Android, so suppress the + // native one (keeps the rest of the HUD: instruments, telemetry, annunciators). + g_hud.show_transport = false; // Bump HUD scale on mobile so values/labels meet M3 readability floors // (~42 px body, ~33 px label at this DPI). Desktop/WASM leave this at 1.0. g_hud.scale_mul = 1.5f; @@ -512,13 +516,16 @@ int main(int argc, char *argv[]) { scene.camera.target = orbit_target; g_last_vehicle_pos = vehicle.position; - // Pick up a .ulg already delivered by an intent before native main() ran. + // Pick up a .ulg already staged into the inbox before native main() ran. try_load_inbox_ulog(&vehicle); while (!WindowShouldClose()) { - // Catch re-share into the running app (HawkeyeActivity.onNewIntent). + // Catch a new log staged while the renderer is already running. try_load_inbox_ulog(&vehicle); + // Apply pending transport requests from the Compose overlay (JVM thread). + replay_control_apply(&g_ds, g_ds_active); + if (g_ds_active) { data_source_poll(&g_ds, GetFrameTime()); vehicle_update(&vehicle, &g_ds.state, &g_ds.home); @@ -535,6 +542,9 @@ int main(int argc, char *argv[]) { } } + // Publish playback status for the Compose overlay to read via JNI. + replay_control_publish(&g_ds, g_ds_active); + handle_touch(&scene.camera, &orbit_target, &prev_count, &prev_touch, &prev_pinch_dist, &prev_mid); diff --git a/android/app/src/main/cpp/replay_control.h b/android/app/src/main/cpp/replay_control.h new file mode 100644 index 0000000..b063335 --- /dev/null +++ b/android/app/src/main/cpp/replay_control.h @@ -0,0 +1,38 @@ +#ifndef REPLAY_CONTROL_H +#define REPLAY_CONTROL_H + +#include +#include + +struct data_source; + +/* + * Lock-free bridge between the JVM main thread (JNI setters/getter, driven by the + * Compose transport overlay) and raylib's render thread (which owns g_ds). The JVM + * posts control *requests*; the render loop consumes them each frame via + * replay_control_apply() and publishes a status *snapshot* via replay_control_publish(). + * The render thread is the only writer of g_ds.playback, so there are no data races. + */ +typedef struct { + // Requests: written by the JVM, consumed (cleared) by the render thread. + atomic_int req_pause; // -1 = no change, 0 = play, 1 = pause + atomic_bool req_speed_set; + _Atomic float req_speed; + atomic_bool req_seek_set; + _Atomic float req_seek_s; + + // Snapshot: written by the render thread, read by the JVM. + atomic_bool snap_active; + atomic_bool snap_paused; + _Atomic float snap_pos_s; + _Atomic float snap_dur_s; + _Atomic float snap_speed; +} replay_control_t; + +extern replay_control_t g_replay_control; + +// Render-thread side, called from the android_main loop each frame. +void replay_control_apply(struct data_source *ds, bool active); +void replay_control_publish(struct data_source *ds, bool active); + +#endif diff --git a/android/app/src/main/cpp/replay_jni.c b/android/app/src/main/cpp/replay_jni.c new file mode 100644 index 0000000..026cd68 --- /dev/null +++ b/android/app/src/main/cpp/replay_jni.c @@ -0,0 +1,75 @@ +#include "replay_control.h" +#include "data_source.h" +#include + +// req_pause starts at -1 (no change); every other field is fine zero-initialized. +replay_control_t g_replay_control = { .req_pause = -1 }; + +void replay_control_apply(struct data_source *ds, bool active) { + if (!active) return; + + int pause = atomic_exchange(&g_replay_control.req_pause, -1); + if (pause == 0) ds->playback.paused = false; + else if (pause == 1) ds->playback.paused = true; + + if (atomic_exchange(&g_replay_control.req_speed_set, false)) { + ds->playback.speed = atomic_load(&g_replay_control.req_speed); + } + if (atomic_exchange(&g_replay_control.req_seek_set, false)) { + data_source_seek(ds, atomic_load(&g_replay_control.req_seek_s)); + } +} + +void replay_control_publish(struct data_source *ds, bool active) { + atomic_store(&g_replay_control.snap_active, active); + if (active) { + atomic_store(&g_replay_control.snap_paused, ds->playback.paused); + atomic_store(&g_replay_control.snap_pos_s, ds->playback.position_s); + atomic_store(&g_replay_control.snap_dur_s, ds->playback.duration_s); + atomic_store(&g_replay_control.snap_speed, ds->playback.speed); + } +} + +// --- JNI surface for com.px4.hawkeye.android.render.NativeReplayController --- + +JNIEXPORT void JNICALL +Java_com_px4_hawkeye_android_render_NativeReplayController_nativeSetPaused( + JNIEnv *env, jobject thiz, jboolean paused) { + (void)env; (void)thiz; + atomic_store(&g_replay_control.req_pause, paused ? 1 : 0); +} + +JNIEXPORT void JNICALL +Java_com_px4_hawkeye_android_render_NativeReplayController_nativeSetSpeed( + JNIEnv *env, jobject thiz, jfloat speed) { + (void)env; (void)thiz; + atomic_store(&g_replay_control.req_speed, speed); + atomic_store(&g_replay_control.req_speed_set, true); +} + +JNIEXPORT void JNICALL +Java_com_px4_hawkeye_android_render_NativeReplayController_nativeSeekTo( + JNIEnv *env, jobject thiz, jfloat seconds) { + (void)env; (void)thiz; + atomic_store(&g_replay_control.req_seek_s, seconds); + atomic_store(&g_replay_control.req_seek_set, true); +} + +// Returns [active, paused, positionS, durationS, speed] (bools as 0/1). +JNIEXPORT jfloatArray JNICALL +Java_com_px4_hawkeye_android_render_NativeReplayController_nativeGetStatus( + JNIEnv *env, jobject thiz) { + (void)thiz; + jfloat values[5]; + values[0] = atomic_load(&g_replay_control.snap_active) ? 1.0f : 0.0f; + values[1] = atomic_load(&g_replay_control.snap_paused) ? 1.0f : 0.0f; + values[2] = atomic_load(&g_replay_control.snap_pos_s); + values[3] = atomic_load(&g_replay_control.snap_dur_s); + values[4] = atomic_load(&g_replay_control.snap_speed); + + jfloatArray array = (*env)->NewFloatArray(env, 5); + if (array != NULL) { + (*env)->SetFloatArrayRegion(env, array, 0, 5, values); + } + return array; +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeActivity.kt b/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeActivity.kt index e35b2e8..d54e8e8 100644 --- a/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeActivity.kt +++ b/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeActivity.kt @@ -2,10 +2,15 @@ package com.px4.hawkeye.android import android.app.NativeActivity import android.graphics.Color +import android.graphics.PixelFormat +import android.os.Build import android.os.Bundle -import android.view.ViewGroup -import android.widget.FrameLayout +import android.view.Gravity +import android.view.WindowManager import androidx.compose.ui.platform.ComposeView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -19,29 +24,32 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.px4.hawkeye.android.render.RenderSession +import com.px4.hawkeye.android.render.NativeReplayController +import com.px4.hawkeye.android.render.transport.TransportRoot +import com.px4.hawkeye.android.render.transport.TransportViewModel import com.px4.hawkeye.core.designsystem.HawkeyeTheme -import com.px4.hawkeye.feature.replay.presentation.ReplayAction -import com.px4.hawkeye.feature.replay.presentation.ReplayRoot -import com.px4.hawkeye.feature.replay.presentation.ReplayViewModel -import org.koin.android.ext.android.getKoin /** - * The renderer host. C/raylib owns the GL surface; this class only adds a Compose - * overlay for the "No file loaded" empty-state dialog. + * The renderer host. C/raylib owns the GL surface; this class adds a Compose overlay for + * the touch transport controls (play/pause, scrub, speed), which drive the native engine + * through [NativeReplayController]'s JNI surface. * - * VIEW/SEND intents are NOT handled here — they go through [IntentRouterActivity], which - * does the file ingest and then starts this activity with no intent data. That isolation - * is what fixes the `DrawMesh+76` crash: cold-launching a NativeActivity with a VIEW - * intent races Raylib's `InitGraphicsDevice` against the system's - * `screenOrientation="landscape"` enforcement, leaving the EGL context in a state where - * `glCreateShader`/`glGenTextures` return 0 and the materials end up with `shader.locs = NULL`. + * Because raylib owns the whole NativeActivity window surface, an inline overlay would be + * clobbered by GL buffer swaps. Instead the overlay lives in a dedicated full-width + * `WindowManager` panel window layered above the renderer (see [attachTransportOverlay]). + * Creating that window ourselves lets us set `layoutInDisplayCutoutMode = ALWAYS` and + * `MATCH_PARENT` width up front, so the bar spans edge to edge under the cutout — which a + * Compose `Popup` window cannot do. * - * NativeActivity extends [android.app.Activity], not [androidx.activity.ComponentActivity], - * so the lifecycle / ViewModelStore / SavedStateRegistry owners that Compose + Koin need - * are implemented by hand — same call ordering ComponentActivity uses internally - * (attach/restore before super.onCreate, ON_PAUSE/STOP/DESTROY before super, - * ON_START/RESUME after super). + * Runs in its own `:renderer` process (manifest) and hard-exits on teardown ([onDestroy]): + * raylib's Android render loop parks in `ALooper_pollAll` once the window is torn down and + * never returns, so a graceful destroy would block the process main thread. Halting keeps + * the Compose shell responsive and guarantees a clean raylib cold start per replay; it also + * tears down the panel window with the process. + * + * `NativeActivity` extends bare `android.app.Activity`, so the Lifecycle / ViewModelStore / + * SavedStateRegistry owners that Compose needs are implemented by hand. Koin is not started + * in `:renderer`, so the ViewModel is built with a plain [ViewModelProvider.Factory]. */ class HawkeyeActivity : NativeActivity(), @@ -59,21 +67,14 @@ class HawkeyeActivity : override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry - /** - * Koin's `by viewModel()` is restricted to `ComponentActivity` / `Fragment`, and - * `NativeActivity` extends bare `android.app.Activity`. We get the same scoping - * behavior by going through `ViewModelProvider` directly with a factory that - * delegates to Koin — the resulting VM lives in [viewModelStoreInstance], so - * `viewModelStoreInstance.clear()` in [onDestroy] fires `ViewModel.onCleared()` - * and `viewModelScope` is cancelled instead of leaking past the activity. - */ - private val koinViewModelFactory = object : ViewModelProvider.Factory { + private val transportViewModelFactory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - getKoin().get(clazz = modelClass.kotlin) as T + TransportViewModel(NativeReplayController()) as T } - private lateinit var viewModel: ReplayViewModel + private lateinit var viewModel: TransportViewModel + private var transportOverlay: ComposeView? = null override fun onCreate(savedInstanceState: Bundle?) { savedStateRegistryController.performAttach() @@ -81,23 +82,76 @@ class HawkeyeActivity : super.onCreate(savedInstanceState) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - viewModel = ViewModelProvider(this, koinViewModelFactory)[ReplayViewModel::class.java] - attachComposeOverlay() - val session = RenderSession.fromExtras( - mapOf( - RenderSession.KEY_MODE to intent?.getStringExtra(RenderSession.KEY_MODE), - RenderSession.KEY_PATH to intent?.getStringExtra(RenderSession.KEY_PATH), - RenderSession.KEY_HOST to intent?.getStringExtra(RenderSession.KEY_HOST), - RenderSession.KEY_PORT to intent?.getStringExtra(RenderSession.KEY_PORT), - ) - ) - // Plan 1: only the legacy inbox/trampoline path drives playback. A non-null - // session is plumbed here so Plan 2 (Replay.filePath) and Plan 3 (Live) can act - // on it; for now Replay still relies on the inbox sentinel and Live is a no-op. - val fromFreshIngest = - session is RenderSession.Replay || - intent?.getBooleanExtra(EXTRA_FROM_TRAMPOLINE, false) == true - viewModel.onAction(ReplayAction.OnAppStarted(fromFreshIngest = fromFreshIngest)) + goEdgeToEdge() + viewModel = ViewModelProvider(this, transportViewModelFactory)[TransportViewModel::class.java] + } + + /** Draw under the system bars + cutout and hide the bars for a clean fullscreen replay. */ + private fun goEdgeToEdge() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.attributes = window.attributes.apply { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + WindowCompat.setDecorFitsSystemWindows(window, false) + hideSystemBars() + } + + private fun hideSystemBars() { + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + // raylib/NativeActivity resets system UI on focus changes; re-assert immersive. + hideSystemBars() + // The window is attached and has a valid token by now, so the panel can be added. + attachTransportOverlay() + } + } + + /** + * Adds the transport overlay as a full-width sub-window above the renderer. Full width + + * cutout-always (set at creation) make the bar reach both edges; NOT_FOCUSABLE leaves + * Back to the renderer, NOT_TOUCH_MODAL + a wrap-height top window let camera gestures + * below the bar fall through to the GL surface. + */ + private fun attachTransportOverlay() { + if (transportOverlay != null) return + val composeView = ComposeView(this).apply { + setBackgroundColor(Color.TRANSPARENT) + setViewTreeLifecycleOwner(this@HawkeyeActivity) + setViewTreeViewModelStoreOwner(this@HawkeyeActivity) + setViewTreeSavedStateRegistryOwner(this@HawkeyeActivity) + setContent { + HawkeyeTheme { + TransportRoot(viewModel = viewModel) + } + } + } + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT, + ).apply { + token = window.decorView.windowToken + gravity = Gravity.TOP or Gravity.START + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS + } + } + windowManager.addView(composeView, params) + transportOverlay = composeView } override fun onStart() { @@ -126,40 +180,9 @@ class HawkeyeActivity : } override fun onDestroy() { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - if (!isChangingConfigurations) viewModelStoreInstance.clear() - super.onDestroy() - } - - private fun attachComposeOverlay() { - val composeView = ComposeView(this).apply { setBackgroundColor(Color.TRANSPARENT) } - // Owners must be set before addContentView — the Compose recomposer binds to - // ViewTreeLifecycleOwner at attach time. - composeView.setViewTreeLifecycleOwner(this) - composeView.setViewTreeViewModelStoreOwner(this) - composeView.setViewTreeSavedStateRegistryOwner(this) - - composeView.setContent { - HawkeyeTheme { - ReplayRoot(viewModel = viewModel) - } - } - - window.addContentView( - composeView, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - } - - companion object { - /** - * Set by `IntentRouterActivity` on the intent it uses to launch us, so we - * know the inbox holds a file the user just explicitly opened (and we - * shouldn't wipe it on cold-launch like we do for a plain icon tap). - */ - const val EXTRA_FROM_TRAMPOLINE = "com.px4.hawkeye.android.from_trampoline" + // Hard-exit the dedicated renderer process rather than block the main thread joining + // the non-terminating native render loop. Halting skips graceful teardown; the OS + // reclaims the process (and the panel window). Preserves the Plan 2a ANR/crash fix. + Runtime.getRuntime().halt(0) } } diff --git a/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeApp.kt b/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeApp.kt index ce9ebbd..d33d0f2 100644 --- a/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeApp.kt +++ b/android/app/src/main/java/com/px4/hawkeye/android/HawkeyeApp.kt @@ -14,6 +14,10 @@ import org.koin.core.context.startKoin class HawkeyeApp : Application() { override fun onCreate() { super.onCreate() + // The renderer runs in a separate ":renderer" process (see HawkeyeActivity) and is a + // bare NativeActivity that needs no DI. Only wire Koin in the main process — this also + // prevents two processes from opening the Room database concurrently. + if (Application.getProcessName() != packageName) return startKoin { androidContext(this@HawkeyeApp) modules( diff --git a/android/app/src/main/java/com/px4/hawkeye/android/IntentRouterActivity.kt b/android/app/src/main/java/com/px4/hawkeye/android/IntentRouterActivity.kt deleted file mode 100644 index b0e0e83..0000000 --- a/android/app/src/main/java/com/px4/hawkeye/android/IntentRouterActivity.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.px4.hawkeye.android - -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.px4.hawkeye.core.designsystem.HawkeyeTheme -import com.px4.hawkeye.core.presentation.ObserveAsEvents -import com.px4.hawkeye.core.presentation.asString -import com.px4.hawkeye.feature.replay.presentation.ReplayAction -import com.px4.hawkeye.feature.replay.presentation.ReplayEvent -import com.px4.hawkeye.feature.replay.presentation.ReplayScreen -import com.px4.hawkeye.feature.replay.presentation.ReplayViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -/** - * Trampoline activity that owns every inbound VIEW/SEND intent. Renders the "Open ULog?" - * confirm dialog over the current foreground task, ingests the file via - * [com.px4.hawkeye.feature.replay.data.AndroidUlogInboxDataSource], then hands off to - * [HawkeyeActivity] with a plain `startActivity` (no intent action/data). - * - * Why this exists: cold-launching `NativeActivity` with a VIEW/SEND intent races Raylib's - * `InitGraphicsDevice` against the system's orientation/surface setup — `glCreateShader` - * returns 0, the default shader/texture fail to load, and the first `DrawMesh` segfaults - * on `material.shader.locs[12]`. By routing intents through a plain `ComponentActivity`, - * `HawkeyeActivity` is only ever launched the boring way (no action, no data), which the - * logs confirm always succeeds. - */ -class IntentRouterActivity : ComponentActivity() { - - private val viewModel: ReplayViewModel by viewModel() - - /** - * True after the user has tapped Open. We only start [HawkeyeActivity] when the - * subsequent `ShowToast` event arrives — distinguishing the ingest-finished case - * from a preview-failure toast that should just close the trampoline. - */ - private var userConfirmed = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val uri = extractUri(intent)?.toString() - if (uri == null) { - // No URI to act on — just send the user to the renderer with the same - // "no fresh ingest" semantics as a launcher tap (inbox gets wiped). - startActivity(Intent(this, HawkeyeActivity::class.java)) - finish() - return - } - - viewModel.onAction(ReplayAction.OnIntentReceived(uri)) - - setContent { - HawkeyeTheme { - val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - - ObserveAsEvents(viewModel.events) { event -> - when (event) { - is ReplayEvent.ShowToast -> { - Toast.makeText( - context, - event.text.asString(context), - Toast.LENGTH_SHORT - ).show() - if (userConfirmed) { - startActivity( - Intent(this@IntentRouterActivity, HawkeyeActivity::class.java) - .putExtra(HawkeyeActivity.EXTRA_FROM_TRAMPOLINE, true) - ) - } - finish() - } - } - } - - ReplayScreen(state = state, onAction = ::handleAction) - } - } - } - - /** - * With `launchMode="singleInstance"` a second share while the dialog is still up - * is delivered here rather than to a new activity instance. Forward the new URI to - * the VM (whose [com.px4.hawkeye.feature.replay.presentation.ReplayViewModel.onAction] - * already cancels the in-flight `previewJob` and replaces the `ConfirmOpen` state), - * and reset [userConfirmed] so a stale tap on the previous file's Open button can't - * leak into the new flow. - */ - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - val uri = extractUri(intent)?.toString() - if (uri == null) { - startActivity(Intent(this, HawkeyeActivity::class.java)) - finish() - return - } - userConfirmed = false - viewModel.onAction(ReplayAction.OnIntentReceived(uri)) - } - - private fun handleAction(action: ReplayAction) { - when (action) { - ReplayAction.OnConfirmOpen -> { - userConfirmed = true - viewModel.onAction(action) - } - ReplayAction.OnDismissDialog -> { - viewModel.onAction(action) - finish() - } - else -> viewModel.onAction(action) - } - } - - private fun extractUri(intent: Intent?): Uri? { - if (intent == null) return null - return when (intent.action) { - Intent.ACTION_VIEW -> intent.data - Intent.ACTION_SEND -> getStreamExtra(intent) - else -> null - } - } - - private fun getStreamExtra(intent: Intent): Uri? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_STREAM) - } -} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/di/AppModule.kt b/android/app/src/main/java/com/px4/hawkeye/android/di/AppModule.kt index c41c4f5..78422d1 100644 --- a/android/app/src/main/java/com/px4/hawkeye/android/di/AppModule.kt +++ b/android/app/src/main/java/com/px4/hawkeye/android/di/AppModule.kt @@ -1,10 +1,13 @@ package com.px4.hawkeye.android.di +import com.px4.hawkeye.android.replay.AndroidReplayPlaybackLauncher import com.px4.hawkeye.android.shell.ShellViewModel +import com.px4.hawkeye.core.presentation.ReplayPlaybackLauncher import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module -/** App-level DI: shell-scoped ViewModels that aren't owned by a feature module. */ +/** App-level DI: shell-scoped ViewModels and seams that wire features to the renderer. */ val appModule = module { viewModelOf(::ShellViewModel) + single { AndroidReplayPlaybackLauncher() } } diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/NativeReplayController.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/NativeReplayController.kt new file mode 100644 index 0000000..e55ad8a --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/NativeReplayController.kt @@ -0,0 +1,55 @@ +package com.px4.hawkeye.android.render + +/** Snapshot of native replay playback state. */ +data class ReplayStatus( + val active: Boolean, + val paused: Boolean, + val positionS: Float, + val durationS: Float, + val speed: Float, +) + +/** + * Controls the native replay engine. The implementation talks to C via JNI; tests use a + * fake. All calls are made from the JVM main thread and are marshalled to the render + * thread through a lock-free control surface (see `replay_control.h`). + */ +interface ReplayController { + fun status(): ReplayStatus + fun setPaused(paused: Boolean) + fun setSpeed(speed: Float) + fun seekTo(seconds: Float) +} + +/** JNI-backed [ReplayController]. Only used inside the `:renderer` process. */ +class NativeReplayController : ReplayController { + + override fun status(): ReplayStatus { + val s = nativeGetStatus() + // [active, paused, positionS, durationS, speed] + if (s.size < 5) return ReplayStatus(active = false, paused = false, 0f, 0f, 1f) + return ReplayStatus( + active = s[0] != 0f, + paused = s[1] != 0f, + positionS = s[2], + durationS = s[3], + speed = s[4], + ) + } + + override fun setPaused(paused: Boolean) = nativeSetPaused(paused) + override fun setSpeed(speed: Float) = nativeSetSpeed(speed) + override fun seekTo(seconds: Float) = nativeSeekTo(seconds) + + private external fun nativeGetStatus(): FloatArray + private external fun nativeSetPaused(paused: Boolean) + private external fun nativeSetSpeed(speed: Float) + private external fun nativeSeekTo(seconds: Float) + + companion object { + init { + // Already loaded by native_app_glue when the NativeActivity starts; idempotent. + System.loadLibrary("hawkeye") + } + } +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/RenderSession.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/RenderSession.kt deleted file mode 100644 index 226e1b0..0000000 --- a/android/app/src/main/java/com/px4/hawkeye/android/render/RenderSession.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.px4.hawkeye.android.render - -/** - * Typed description of what the renderer should show, passed from the shell to - * [com.px4.hawkeye.android.HawkeyeActivity] as intent extras. Encoded as a plain - * String map so it maps 1:1 onto Intent extras and is unit-testable without Android. - */ -sealed interface RenderSession { - data class Replay(val filePath: String) : RenderSession - data class Live(val host: String, val port: Int) : RenderSession - - fun toExtras(): Map = when (this) { - is Replay -> mapOf(KEY_MODE to MODE_REPLAY, KEY_PATH to filePath) - is Live -> mapOf(KEY_MODE to MODE_LIVE, KEY_HOST to host, KEY_PORT to port.toString()) - } - - companion object { - const val KEY_MODE = "com.px4.hawkeye.render.mode" - const val KEY_PATH = "com.px4.hawkeye.render.path" - const val KEY_HOST = "com.px4.hawkeye.render.host" - const val KEY_PORT = "com.px4.hawkeye.render.port" - const val MODE_REPLAY = "replay" - const val MODE_LIVE = "live" - - fun fromExtras(extras: Map): RenderSession? = - when (extras[KEY_MODE]) { - MODE_REPLAY -> extras[KEY_PATH]?.let { Replay(it) } - MODE_LIVE -> { - val host = extras[KEY_HOST] - val port = extras[KEY_PORT]?.toIntOrNull() - if (host != null && port != null) Live(host, port) else null - } - else -> null - } - } -} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/RendererLauncher.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/RendererLauncher.kt index fb928d3..22024da 100644 --- a/android/app/src/main/java/com/px4/hawkeye/android/render/RendererLauncher.kt +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/RendererLauncher.kt @@ -4,11 +4,13 @@ import android.content.Context import android.content.Intent import com.px4.hawkeye.android.HawkeyeActivity -/** Single place the shell uses to launch the native renderer for a given session. */ +/** + * Single place the shell uses to launch the native renderer. The log to play is delivered + * out of band: the repository stages it into `filesDir/inbox/` and the native poll loop + * loads it, so the launch itself carries no data. + */ object RendererLauncher { - fun launch(context: Context, session: RenderSession) { - val intent = Intent(context, HawkeyeActivity::class.java) - session.toExtras().forEach { (k, v) -> intent.putExtra(k, v) } - context.startActivity(intent) + fun launch(context: Context) { + context.startActivity(Intent(context, HawkeyeActivity::class.java)) } } diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportAction.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportAction.kt new file mode 100644 index 0000000..031ed0b --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportAction.kt @@ -0,0 +1,8 @@ +package com.px4.hawkeye.android.render.transport + +sealed interface TransportAction { + data object OnPlayPause : TransportAction + /** [fraction] is 0..1 along the timeline. */ + data class OnSeek(val fraction: Float) : TransportAction + data object OnCycleSpeed : TransportAction +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportScreen.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportScreen.kt new file mode 100644 index 0000000..5bf2d36 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportScreen.kt @@ -0,0 +1,129 @@ +package com.px4.hawkeye.android.render.transport + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.px4.hawkeye.android.R +import com.px4.hawkeye.core.designsystem.HawkeyeAlpha +import com.px4.hawkeye.core.designsystem.HawkeyeDimens +import com.px4.hawkeye.core.designsystem.HawkeyeTheme +import com.px4.hawkeye.core.designsystem.glassSurface +import kotlinx.coroutines.delay +import java.util.Locale + +/** + * Hosted in a dedicated full-width WindowManager panel above the renderer (see + * HawkeyeActivity), so the bar itself just fills its window — no Popup, no inset gap. + */ +@Composable +fun TransportRoot(viewModel: TransportViewModel) { + val state by viewModel.state.collectAsState() + + // Pull native playback status on a cadence; the engine has no push channel. + LaunchedEffect(Unit) { + while (true) { + viewModel.refresh() + delay(TransportViewModel.POLL_INTERVAL_MS) + } + } + + TransportScreen(state = state, onAction = viewModel::onAction) +} + +@Composable +fun TransportScreen( + state: TransportState, + onAction: (TransportAction) -> Unit, +) { + // Nothing until a log is loaded; the host window collapses to zero height. + if (!state.isActive) return + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.glassSurface, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = HawkeyeDimens.contentPadding, + vertical = HawkeyeDimens.rowVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(HawkeyeDimens.inlineSpacing), + ) { + TextButton(onClick = { onAction(TransportAction.OnPlayPause) }) { + Text( + text = stringResource( + if (state.isPaused) R.string.transport_play else R.string.transport_pause, + ), + ) + } + + Text( + text = formatTime(state.positionMs), + style = MaterialTheme.typography.labelMedium, + ) + + Slider( + value = fractionOf(state.positionMs, state.durationMs), + onValueChange = { onAction(TransportAction.OnSeek(it)) }, + modifier = Modifier + .weight(1f) + .padding(horizontal = HawkeyeDimens.inlineSpacing), + ) + + Text( + text = formatTime(state.durationMs), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HawkeyeAlpha.CARD_CAPTION), + ) + + TextButton(onClick = { onAction(TransportAction.OnCycleSpeed) }) { + Text(formatSpeed(state.speed)) + } + } + } +} + +private fun fractionOf(positionMs: Long, durationMs: Long): Float = + if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f + +private fun formatTime(ms: Long): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + return String.format(Locale.US, "%d:%02d", totalSeconds / 60, totalSeconds % 60) +} + +private fun formatSpeed(speed: Float): String = + if (speed % 1f == 0f) "${speed.toInt()}x" else "${speed}x" + +@Preview(showBackground = true, backgroundColor = 0xFF0B0E13, widthDp = 640) +@Composable +private fun TransportScreenPreview() { + HawkeyeTheme(darkTheme = true) { + TransportScreen( + state = TransportState( + isActive = true, + isPaused = false, + positionMs = 83_000, + durationMs = 222_000, + speed = 2f, + ), + onAction = {}, + ) + } +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportState.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportState.kt new file mode 100644 index 0000000..ea7bab7 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportState.kt @@ -0,0 +1,12 @@ +package com.px4.hawkeye.android.render.transport + +import androidx.compose.runtime.Stable + +@Stable +data class TransportState( + val isActive: Boolean = false, + val isPaused: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val speed: Float = 1f, +) diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportViewModel.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportViewModel.kt new file mode 100644 index 0000000..768c8e7 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/transport/TransportViewModel.kt @@ -0,0 +1,65 @@ +package com.px4.hawkeye.android.render.transport + +import androidx.lifecycle.ViewModel +import com.px4.hawkeye.android.render.ReplayController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Backs the touch transport overlay. State is a pull-based snapshot of the native engine: + * [refresh] reads the current status (the Root polls it on a cadence), and [onAction] + * forwards user input to the [controller]. No coroutines/`viewModelScope` here — the + * polling cadence lives in the Root as a lifecycle side effect, which keeps this fully + * synchronous and unit-testable. + */ +class TransportViewModel( + private val controller: ReplayController, +) : ViewModel() { + + private val _state = MutableStateFlow(TransportState()) + val state = _state.asStateFlow() + + fun refresh() { + val status = controller.status() + _state.update { + it.copy( + isActive = status.active, + isPaused = status.paused, + positionMs = (status.positionS * MILLIS).toLong(), + durationMs = (status.durationS * MILLIS).toLong(), + speed = status.speed, + ) + } + } + + fun onAction(action: TransportAction) { + when (action) { + TransportAction.OnPlayPause -> { + val paused = !_state.value.isPaused + controller.setPaused(paused) + _state.update { it.copy(isPaused = paused) } + } + is TransportAction.OnSeek -> { + val durationS = _state.value.durationMs / MILLIS + controller.seekTo(action.fraction.coerceIn(0f, 1f) * durationS) + } + TransportAction.OnCycleSpeed -> { + val next = nextSpeed(_state.value.speed) + controller.setSpeed(next) + _state.update { it.copy(speed = next) } + } + } + } + + private fun nextSpeed(current: Float): Float { + val index = SPEEDS.indexOfFirst { it >= current - 0.01f }.coerceAtLeast(0) + return SPEEDS[(index + 1) % SPEEDS.size] + } + + companion object { + const val POLL_INTERVAL_MS = 100L + private const val MILLIS = 1000f + private val SPEEDS = listOf(0.5f, 1f, 2f, 4f) + } +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/replay/AndroidReplayPlaybackLauncher.kt b/android/app/src/main/java/com/px4/hawkeye/android/replay/AndroidReplayPlaybackLauncher.kt new file mode 100644 index 0000000..337504b --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/replay/AndroidReplayPlaybackLauncher.kt @@ -0,0 +1,17 @@ +package com.px4.hawkeye.android.replay + +import android.content.Context +import com.px4.hawkeye.android.render.RendererLauncher +import com.px4.hawkeye.core.presentation.ReplayPlaybackLauncher + +/** + * App-side implementation of the Replay feature's [ReplayPlaybackLauncher] seam. By the + * time this runs the repository has already copied the selected log into the renderer + * inbox and bumped the sentinel, so this only needs to start the renderer (which loads + * from the inbox). [entryId] is unused for now. + */ +class AndroidReplayPlaybackLauncher : ReplayPlaybackLauncher { + override fun launch(context: Context, entryId: String) { + RendererLauncher.launch(context) + } +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/shell/AppShell.kt b/android/app/src/main/java/com/px4/hawkeye/android/shell/AppShell.kt index 4311c94..5e25403 100644 --- a/android/app/src/main/java/com/px4/hawkeye/android/shell/AppShell.kt +++ b/android/app/src/main/java/com/px4/hawkeye/android/shell/AppShell.kt @@ -51,9 +51,12 @@ fun AppShell(viewModel: ShellViewModel = koinViewModel()) { Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { // Looping video (+ a legibility scrim) behind the entire Home screen, including the - // nav bar, shown only while the Home tab is active. The translucent nav bar and - // cards let it show through. Released when leaving the Home tab. - if (backStacks.selected == TopLevelDestination.HOME) { + // nav bar. Shown only while Home is the selected tab AND at the root of its back + // stack: navigating deeper (Replay/Live render over an opaque surface) releases the + // ExoPlayer via HomeVideoBackground's DisposableEffect instead of decoding unseen. + if (backStacks.selected == TopLevelDestination.HOME && + backStacks.current.lastOrNull() == HomeKey + ) { HomeVideoBackground(modifier = Modifier.matchParentSize()) Box(modifier = Modifier.matchParentSize().background(ScrimColor)) } @@ -101,13 +104,8 @@ fun AppShell(viewModel: ShellViewModel = koinViewModel()) { onNavigateToLive = { backStacks.push(LiveKey) }, ) } - addEntryProvider(ReplayKey::class, { it.toString() }) { - ComingSoonScreen( - titleRes = R.string.shell_replay_title, - messageRes = R.string.shell_replay_coming_soon, - onBack = { backStacks.pop() }, - ) - } + // Replay's NavEntry is contributed by the feature's EntryProviderInstaller + // (collected into `installers` below). Live is still a placeholder. addEntryProvider(LiveKey::class, { it.toString() }) { ComingSoonScreen( titleRes = R.string.shell_live_title, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e143140..98868d6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,9 +6,11 @@ Settings - Replay - The replay library and file picker are coming in Plan 2. Live / SITL Connecting to a simulator is coming in Plan 3. Back + + + Play + Pause diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index d899154..9264e8e 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -3,13 +3,4 @@ chrome (dialogs) renders through Compose Material 3, which provides its own theming. This activity theme only styles system bars and the brief first frame. --> diff --git a/android/app/src/test/java/com/px4/hawkeye/android/render/RenderSessionTest.kt b/android/app/src/test/java/com/px4/hawkeye/android/render/RenderSessionTest.kt deleted file mode 100644 index 51bcf56..0000000 --- a/android/app/src/test/java/com/px4/hawkeye/android/render/RenderSessionTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.px4.hawkeye.android.render - -import assertk.assertThat -import assertk.assertions.isEqualTo -import assertk.assertions.isInstanceOf -import assertk.assertions.isNotNull -import assertk.assertions.isNull -import org.junit.jupiter.api.Test - -class RenderSessionTest { - - @Test - fun `replay session round-trips through a bundle-like map`() { - val extras = RenderSession.Replay(filePath = "/data/library/abc.ulg").toExtras() - val decoded = RenderSession.fromExtras(extras) - assertThat(decoded).isNotNull().isInstanceOf(RenderSession.Replay::class) - assertThat((decoded as RenderSession.Replay).filePath).isEqualTo("/data/library/abc.ulg") - } - - @Test - fun `live session round-trips`() { - val extras = RenderSession.Live(host = "192.168.1.42", port = 19410).toExtras() - val decoded = RenderSession.fromExtras(extras) - assertThat(decoded).isNotNull().isInstanceOf(RenderSession.Live::class) - val live = decoded as RenderSession.Live - assertThat(live.host).isEqualTo("192.168.1.42") - assertThat(live.port).isEqualTo(19410) - } - - @Test - fun `absent mode decodes to null (legacy inbox launch)`() { - assertThat(RenderSession.fromExtras(emptyMap())).isNull() - } - - @Test - fun `live mode with missing host decodes to null`() { - val extras = mapOf(RenderSession.KEY_MODE to RenderSession.MODE_LIVE, RenderSession.KEY_PORT to "19410") - assertThat(RenderSession.fromExtras(extras)).isNull() - } - - @Test - fun `replay mode with missing path decodes to null`() { - val extras = mapOf(RenderSession.KEY_MODE to RenderSession.MODE_REPLAY) - assertThat(RenderSession.fromExtras(extras)).isNull() - } -} diff --git a/android/app/src/test/java/com/px4/hawkeye/android/render/transport/FakeReplayController.kt b/android/app/src/test/java/com/px4/hawkeye/android/render/transport/FakeReplayController.kt new file mode 100644 index 0000000..bbb1e4a --- /dev/null +++ b/android/app/src/test/java/com/px4/hawkeye/android/render/transport/FakeReplayController.kt @@ -0,0 +1,16 @@ +package com.px4.hawkeye.android.render.transport + +import com.px4.hawkeye.android.render.ReplayController +import com.px4.hawkeye.android.render.ReplayStatus + +class FakeReplayController : ReplayController { + var status = ReplayStatus(active = true, paused = false, positionS = 0f, durationS = 100f, speed = 1f) + var pausedArg: Boolean? = null + var speedArg: Float? = null + var seekArg: Float? = null + + override fun status() = status + override fun setPaused(paused: Boolean) { pausedArg = paused } + override fun setSpeed(speed: Float) { speedArg = speed } + override fun seekTo(seconds: Float) { seekArg = seconds } +} diff --git a/android/app/src/test/java/com/px4/hawkeye/android/render/transport/TransportViewModelTest.kt b/android/app/src/test/java/com/px4/hawkeye/android/render/transport/TransportViewModelTest.kt new file mode 100644 index 0000000..805b80d --- /dev/null +++ b/android/app/src/test/java/com/px4/hawkeye/android/render/transport/TransportViewModelTest.kt @@ -0,0 +1,88 @@ +package com.px4.hawkeye.android.render.transport + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.px4.hawkeye.android.render.ReplayStatus +import org.junit.jupiter.api.Test + +class TransportViewModelTest { + + @Test + fun `refresh maps native status into state`() { + val controller = FakeReplayController().apply { + status = ReplayStatus(active = true, paused = true, positionS = 12f, durationS = 100f, speed = 2f) + } + val vm = TransportViewModel(controller) + + vm.refresh() + + val state = vm.state.value + assertThat(state.isActive).isTrue() + assertThat(state.isPaused).isTrue() + assertThat(state.positionMs).isEqualTo(12_000L) + assertThat(state.durationMs).isEqualTo(100_000L) + assertThat(state.speed).isEqualTo(2f) + } + + @Test + fun `play pause toggles and calls the controller`() { + val controller = FakeReplayController().apply { + status = ReplayStatus(active = true, paused = false, positionS = 0f, durationS = 100f, speed = 1f) + } + val vm = TransportViewModel(controller).apply { refresh() } + + vm.onAction(TransportAction.OnPlayPause) + + assertThat(controller.pausedArg).isEqualTo(true) + assertThat(vm.state.value.isPaused).isTrue() + } + + @Test + fun `seek converts the timeline fraction to seconds`() { + val controller = FakeReplayController().apply { + status = ReplayStatus(active = true, paused = false, positionS = 0f, durationS = 200f, speed = 1f) + } + val vm = TransportViewModel(controller).apply { refresh() } + + vm.onAction(TransportAction.OnSeek(0.5f)) + + assertThat(controller.seekArg).isEqualTo(100f) + } + + @Test + fun `cycle speed advances through the ladder`() { + val controller = FakeReplayController().apply { + status = ReplayStatus(active = true, paused = false, positionS = 0f, durationS = 100f, speed = 1f) + } + val vm = TransportViewModel(controller).apply { refresh() } + + vm.onAction(TransportAction.OnCycleSpeed) + + assertThat(controller.speedArg).isEqualTo(2f) + assertThat(vm.state.value.speed).isEqualTo(2f) + } + + @Test + fun `cycle speed wraps from the top back to the slowest`() { + val controller = FakeReplayController().apply { + status = ReplayStatus(active = true, paused = false, positionS = 0f, durationS = 100f, speed = 4f) + } + val vm = TransportViewModel(controller).apply { refresh() } + + vm.onAction(TransportAction.OnCycleSpeed) + + assertThat(controller.speedArg).isEqualTo(0.5f) + } + + @Test + fun `inactive status keeps the bar hidden`() { + val controller = FakeReplayController().apply { + status = ReplayStatus(active = false, paused = false, positionS = 0f, durationS = 0f, speed = 1f) + } + val vm = TransportViewModel(controller).apply { refresh() } + + assertThat(vm.state.value.isActive).isFalse() + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index d30abb5..e5c9493 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false } diff --git a/android/core/domain/build.gradle.kts b/android/core/domain/build.gradle.kts index 6b40396..b4a799f 100644 --- a/android/core/domain/build.gradle.kts +++ b/android/core/domain/build.gradle.kts @@ -1,3 +1,8 @@ plugins { id("hawkeye.jvm.library") } + +dependencies { + // Flow appears in ReplayLibraryRepository's public API, so expose it transitively. + api(libs.kotlinx.coroutines.core) +} diff --git a/android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/LibraryEntry.kt b/android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/LibraryEntry.kt new file mode 100644 index 0000000..bdc0a97 --- /dev/null +++ b/android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/LibraryEntry.kt @@ -0,0 +1,17 @@ +package com.px4.hawkeye.core.domain + +/** + * A `.ulg` log imported into the in-app library. The payload bytes live on disk under + * `filesDir/library/`; this is the metadata the Replay library and the Home recents peek + * show, and what the repository uses to stage a file for playback. + * + * Lives in `core:domain` because more than one feature consumes it (Replay and Home). + * Parsed flight duration is intentionally absent: extracting it needs the native ULog + * parser/JNI, which arrives with the transport overlay in a later plan. + */ +data class LibraryEntry( + val id: String, + val displayName: String, + val sizeBytes: Long, + val importedAtMillis: Long, +) diff --git a/android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/ReplayLibraryRepository.kt b/android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/ReplayLibraryRepository.kt new file mode 100644 index 0000000..874c74b --- /dev/null +++ b/android/core/domain/src/main/kotlin/com/px4/hawkeye/core/domain/ReplayLibraryRepository.kt @@ -0,0 +1,29 @@ +package com.px4.hawkeye.core.domain + +import kotlinx.coroutines.flow.Flow + +/** + * Coordinates the in-app log library's two sources of truth: persisted metadata and the + * on-disk `.ulg` payloads. "Repository" is warranted because it combines those sources + * rather than wrapping a single one. + * + * Lives in `core:domain` because both the Replay library and the Home recents peek depend + * on it; the concrete implementation lives in `feature:replay:data`. + */ +interface ReplayLibraryRepository { + + /** Imported logs, newest first. Emits again whenever the library changes. */ + fun observeLibrary(): Flow> + + /** Copies the document at [uri] into the library and records its metadata. */ + suspend fun import(uri: String): Result + + /** Removes both the metadata row and the on-disk payload for [id]. */ + suspend fun delete(id: String): EmptyResult + + /** + * Copies the library payload for [id] into the renderer's inbox and bumps the + * sentinel so the native poll loop loads it on the next launch. + */ + suspend fun stageForPlayback(id: String): EmptyResult +} diff --git a/android/core/presentation/src/main/kotlin/com/px4/hawkeye/core/presentation/ReplayPlaybackLauncher.kt b/android/core/presentation/src/main/kotlin/com/px4/hawkeye/core/presentation/ReplayPlaybackLauncher.kt new file mode 100644 index 0000000..9ab00e0 --- /dev/null +++ b/android/core/presentation/src/main/kotlin/com/px4/hawkeye/core/presentation/ReplayPlaybackLauncher.kt @@ -0,0 +1,14 @@ +package com.px4.hawkeye.core.presentation + +import android.content.Context + +/** + * Seam for launching the native renderer. The presentation features (Replay, Home) cannot + * depend on `:app` — where the renderer Activity and its launcher live — so the app + * provides an implementation through Koin and the feature screens invoke it once a log has + * been staged for playback. Shared here in `core:presentation` because both the Replay + * library and the Home recents peek launch playback. + */ +fun interface ReplayPlaybackLauncher { + fun launch(context: Context, entryId: String) +} diff --git a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeAction.kt b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeAction.kt index a1d75a5..4f943e4 100644 --- a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeAction.kt +++ b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeAction.kt @@ -3,4 +3,5 @@ package com.px4.hawkeye.feature.home.presentation sealed interface HomeAction { data object OnReplayClicked : HomeAction data object OnConnectClicked : HomeAction + data class OnRecentClicked(val id: String) : HomeAction } diff --git a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeEvent.kt b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeEvent.kt index 3e611de..fd7ee40 100644 --- a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeEvent.kt +++ b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeEvent.kt @@ -1,6 +1,13 @@ package com.px4.hawkeye.feature.home.presentation +import com.px4.hawkeye.core.presentation.UiText + sealed interface HomeEvent { data object NavigateToReplay : HomeEvent data object NavigateToLive : HomeEvent + + /** A recent log was staged into the inbox; hand off to the renderer. */ + data class PlayRecent(val entryId: String) : HomeEvent + + data class ShowError(val text: UiText) : HomeEvent } diff --git a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeScreen.kt b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeScreen.kt index c7bc343..659948b 100644 --- a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeScreen.kt +++ b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeScreen.kt @@ -1,5 +1,7 @@ package com.px4.hawkeye.feature.home.presentation +import android.widget.Toast +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -16,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -26,20 +29,28 @@ import com.px4.hawkeye.core.designsystem.HawkeyeTheme import com.px4.hawkeye.core.designsystem.MediaTitleShadow import com.px4.hawkeye.core.designsystem.glassSurface import com.px4.hawkeye.core.presentation.ObserveAsEvents +import com.px4.hawkeye.core.presentation.ReplayPlaybackLauncher +import com.px4.hawkeye.core.presentation.asString import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun HomeRoot( onNavigateToReplay: () -> Unit, onNavigateToLive: () -> Unit, viewModel: HomeViewModel = koinViewModel(), + playbackLauncher: ReplayPlaybackLauncher = koinInject(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current ObserveAsEvents(viewModel.events) { event -> when (event) { HomeEvent.NavigateToReplay -> onNavigateToReplay() HomeEvent.NavigateToLive -> onNavigateToLive() + is HomeEvent.PlayRecent -> playbackLauncher.launch(context, event.entryId) + is HomeEvent.ShowError -> + Toast.makeText(context, event.text.asString(context), Toast.LENGTH_SHORT).show() } } @@ -91,6 +102,66 @@ fun HomeScreen( description = stringResource(R.string.home_connect_description), onClick = { onAction(HomeAction.OnConnectClicked) }, ) + + if (state.recents.isNotEmpty()) { + RecentFlights( + recents = state.recents, + onRecentClick = { onAction(HomeAction.OnRecentClicked(it)) }, + ) + } + } +} + +@Composable +private fun RecentFlights( + recents: List, + onRecentClick: (String) -> Unit, +) { + Spacer(modifier = Modifier.height(HawkeyeDimens.sectionSpacing)) + Text( + text = stringResource(R.string.home_recent_header), + style = MaterialTheme.typography.titleMedium, + // Over the video, like the subtitle. + color = Color.White.copy(alpha = HawkeyeAlpha.ON_MEDIA_SECONDARY), + modifier = Modifier.padding(bottom = HawkeyeDimens.titleSpacing), + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.glassSurface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = HawkeyeDimens.cardElevation), + ) { + Column { + recents.forEach { recent -> + RecentRow(recent = recent, onClick = { onRecentClick(recent.id) }) + } + } + } +} + +@Composable +private fun RecentRow( + recent: RecentFlightUi, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(HawkeyeDimens.cardPadding), + ) { + Text( + text = recent.displayName, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = recent.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HawkeyeAlpha.CARD_CAPTION), + modifier = Modifier.padding(top = HawkeyeDimens.captionSpacing), + ) } } @@ -130,6 +201,14 @@ private fun ModeCard( @Composable private fun HomeScreenPreview() { HawkeyeTheme(darkTheme = true) { - HomeScreen(state = HomeState(), onAction = {}) + HomeScreen( + state = HomeState( + recents = listOf( + RecentFlightUi("1", "flight_log.ulg", "May 30, 2026"), + RecentFlightUi("2", "sitl_test.ulg", "May 27, 2026"), + ), + ), + onAction = {}, + ) } } diff --git a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeState.kt b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeState.kt index ba4a97b..e42867a 100644 --- a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeState.kt +++ b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeState.kt @@ -1,3 +1,15 @@ package com.px4.hawkeye.feature.home.presentation -data class HomeState(val placeholder: Boolean = true) +import androidx.compose.runtime.Stable + +@Stable +data class HomeState( + val recents: List = emptyList(), +) + +/** A recent library entry as shown in the Home peek: name plus an imported-date subtitle. */ +data class RecentFlightUi( + val id: String, + val displayName: String, + val subtitle: String, +) diff --git a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModel.kt b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModel.kt index dfb6d79..38ab387 100644 --- a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModel.kt +++ b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModel.kt @@ -2,13 +2,21 @@ package com.px4.hawkeye.feature.home.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.ReplayLibraryRepository +import com.px4.hawkeye.core.domain.onFailure +import com.px4.hawkeye.core.domain.onSuccess +import com.px4.hawkeye.core.presentation.toUiText import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class HomeViewModel : ViewModel() { +class HomeViewModel( + private val repository: ReplayLibraryRepository, +) : ViewModel() { private val _state = MutableStateFlow(HomeState()) val state = _state.asStateFlow() @@ -16,10 +24,31 @@ class HomeViewModel : ViewModel() { private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() + init { + viewModelScope.launch { + repository.observeLibrary().collect { entries -> + _state.update { it.copy(recents = entries.take(MAX_RECENTS).map(LibraryEntry::toRecentUi)) } + } + } + } + fun onAction(action: HomeAction) { when (action) { HomeAction.OnReplayClicked -> viewModelScope.launch { _events.send(HomeEvent.NavigateToReplay) } HomeAction.OnConnectClicked -> viewModelScope.launch { _events.send(HomeEvent.NavigateToLive) } + is HomeAction.OnRecentClicked -> playRecent(action.id) + } + } + + private fun playRecent(id: String) { + viewModelScope.launch { + repository.stageForPlayback(id) + .onSuccess { _events.send(HomeEvent.PlayRecent(id)) } + .onFailure { _events.send(HomeEvent.ShowError(it.toUiText())) } } } + + private companion object { + const val MAX_RECENTS = 3 + } } diff --git a/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/RecentFlightUiMapper.kt b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/RecentFlightUiMapper.kt new file mode 100644 index 0000000..916bd2f --- /dev/null +++ b/android/feature/home/presentation/src/main/kotlin/com/px4/hawkeye/feature/home/presentation/RecentFlightUiMapper.kt @@ -0,0 +1,16 @@ +package com.px4.hawkeye.feature.home.presentation + +import com.px4.hawkeye.core.domain.LibraryEntry +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val recentDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneId.systemDefault()) + +internal fun LibraryEntry.toRecentUi(): RecentFlightUi = RecentFlightUi( + id = id, + displayName = displayName, + subtitle = recentDateFormatter.format(Instant.ofEpochMilli(importedAtMillis)), +) diff --git a/android/feature/home/presentation/src/main/res/values/strings.xml b/android/feature/home/presentation/src/main/res/values/strings.xml index 442776c..3758947 100644 --- a/android/feature/home/presentation/src/main/res/values/strings.xml +++ b/android/feature/home/presentation/src/main/res/values/strings.xml @@ -6,4 +6,5 @@ Load a ULog file and replay recorded flight data Connect to a simulator Stream live telemetry from PX4 SITL or a real vehicle + Recent flights diff --git a/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/FakeReplayLibraryRepository.kt b/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/FakeReplayLibraryRepository.kt new file mode 100644 index 0000000..9a1a0ce --- /dev/null +++ b/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/FakeReplayLibraryRepository.kt @@ -0,0 +1,27 @@ +package com.px4.hawkeye.feature.home.presentation + +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.ReplayLibraryRepository +import com.px4.hawkeye.core.domain.Result +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeReplayLibraryRepository : ReplayLibraryRepository { + + val entriesFlow = MutableStateFlow>(emptyList()) + var stageResult: EmptyResult = Result.Success(Unit) + val stagedIds = mutableListOf() + + override fun observeLibrary() = entriesFlow + + override suspend fun import(uri: String): Result = + Result.Success(LibraryEntry("x", "x.ulg", 0L, 0L)) + + override suspend fun delete(id: String): EmptyResult = Result.Success(Unit) + + override suspend fun stageForPlayback(id: String): EmptyResult { + stagedIds += id + return stageResult + } +} diff --git a/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModelTest.kt b/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModelTest.kt index d953185..2a15db6 100644 --- a/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModelTest.kt +++ b/android/feature/home/presentation/src/test/kotlin/com/px4/hawkeye/feature/home/presentation/HomeViewModelTest.kt @@ -2,7 +2,12 @@ package com.px4.hawkeye.feature.home.presentation import app.cash.turbine.test import assertk.assertThat +import assertk.assertions.containsExactly import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.Result import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -16,12 +21,14 @@ import org.junit.jupiter.api.Test @OptIn(ExperimentalCoroutinesApi::class) class HomeViewModelTest { private val dispatcher = UnconfinedTestDispatcher() - @BeforeEach fun setUp() { Dispatchers.setMain(dispatcher) } + private lateinit var repo: FakeReplayLibraryRepository + + @BeforeEach fun setUp() { Dispatchers.setMain(dispatcher); repo = FakeReplayLibraryRepository() } @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test fun `replay click emits NavigateToReplay`() = runTest { - val vm = HomeViewModel() + val vm = HomeViewModel(repo) vm.events.test { vm.onAction(HomeAction.OnReplayClicked) assertThat(awaitItem()).isEqualTo(HomeEvent.NavigateToReplay) @@ -30,10 +37,45 @@ class HomeViewModelTest { @Test fun `connect click emits NavigateToLive`() = runTest { - val vm = HomeViewModel() + val vm = HomeViewModel(repo) vm.events.test { vm.onAction(HomeAction.OnConnectClicked) assertThat(awaitItem()).isEqualTo(HomeEvent.NavigateToLive) } } + + @Test + fun `library exposes at most three recents in order`() = runTest { + repo.entriesFlow.value = listOf( + LibraryEntry("1", "a.ulg", 1L, 4L), + LibraryEntry("2", "b.ulg", 1L, 3L), + LibraryEntry("3", "c.ulg", 1L, 2L), + LibraryEntry("4", "d.ulg", 1L, 1L), + ) + val vm = HomeViewModel(repo) + vm.state.test { + assertThat(awaitItem().recents.map { it.id }).containsExactly("1", "2", "3") + } + } + + @Test + fun `recent click stages then emits PlayRecent`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 1L, 0L)) + val vm = HomeViewModel(repo) + vm.events.test { + vm.onAction(HomeAction.OnRecentClicked("1")) + assertThat(awaitItem()).isEqualTo(HomeEvent.PlayRecent("1")) + } + assertThat(repo.stagedIds).containsExactly("1") + } + + @Test + fun `recent click stage failure emits ShowError`() = runTest { + repo.stageResult = Result.Error(DataError.Local.NOT_FOUND) + val vm = HomeViewModel(repo) + vm.events.test { + vm.onAction(HomeAction.OnRecentClicked("1")) + assertThat(awaitItem()).isInstanceOf(HomeEvent.ShowError::class) + } + } } diff --git a/android/feature/replay/data/build.gradle.kts b/android/feature/replay/data/build.gradle.kts index 8089cb4..06032b3 100644 --- a/android/feature/replay/data/build.gradle.kts +++ b/android/feature/replay/data/build.gradle.kts @@ -1,14 +1,23 @@ plugins { id("hawkeye.android.library") + alias(libs.plugins.ksp) } android { namespace = "com.px4.hawkeye.feature.replay.data" } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.generateKotlin", "true") +} + dependencies { implementation(project(":core:domain")) - api(project(":feature:replay:domain")) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) implementation(libs.kotlinx.coroutines.android) implementation(libs.koin.android) diff --git a/android/feature/replay/data/schemas/com.px4.hawkeye.feature.replay.data.db.ReplayDatabase/1.json b/android/feature/replay/data/schemas/com.px4.hawkeye.feature.replay.data.db.ReplayDatabase/1.json new file mode 100644 index 0000000..720a19f --- /dev/null +++ b/android/feature/replay/data/schemas/com.px4.hawkeye.feature.replay.data.db.ReplayDatabase/1.json @@ -0,0 +1,55 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "21679f9e66798427f666cc6c22b070b8", + "entities": [ + { + "tableName": "library_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `size_bytes` INTEGER NOT NULL, `imported_at_millis` INTEGER NOT NULL, `file_name` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMillis", + "columnName": "imported_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21679f9e66798427f666cc6c22b070b8')" + ] + } +} \ No newline at end of file diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidReplayFileManager.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidReplayFileManager.kt new file mode 100644 index 0000000..1c89de8 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidReplayFileManager.kt @@ -0,0 +1,50 @@ +package com.px4.hawkeye.feature.replay.data + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.Result + +/** + * SAF-backed [ReplayFileManager]: resolves a picked document's name and streams its bytes + * into the library via [LibraryFileStore]. The file-system work itself lives in the store + * so it stays unit-testable; this class is the thin Android-content glue. + */ +class AndroidReplayFileManager( + private val contentResolver: ContentResolver, + private val store: LibraryFileStore, +) : ReplayFileManager { + + override fun import(uri: String, fileName: String): Result { + val input = runCatching { contentResolver.openInputStream(Uri.parse(uri)) }.getOrNull() + ?: return Result.Error(DataError.Local.NOT_FOUND) + return store.write(input, fileName) + } + + override fun stage(fileName: String): EmptyResult = store.stage(fileName) + + override fun delete(fileName: String) = store.delete(fileName) + + override fun resolveDisplayName(uri: String): String { + val parsed = Uri.parse(uri) + if (parsed.scheme == ContentResolver.SCHEME_CONTENT) { + runCatching { + contentResolver.query( + parsed, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) cursor.getString(index)?.let { return it } + } + } + } + } + return parsed.lastPathSegment ?: uri + } +} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidUlogInboxDataSource.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidUlogInboxDataSource.kt deleted file mode 100644 index 8c553e7..0000000 --- a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/AndroidUlogInboxDataSource.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.px4.hawkeye.feature.replay.data - -import android.content.ContentResolver -import android.net.Uri -import android.provider.OpenableColumns -import android.util.Log -import com.px4.hawkeye.core.domain.EmptyResult -import com.px4.hawkeye.core.domain.Result -import com.px4.hawkeye.feature.replay.domain.ReplayError -import com.px4.hawkeye.feature.replay.domain.UlogFile -import com.px4.hawkeye.feature.replay.domain.UlogInboxDataSource -import com.px4.hawkeye.feature.replay.domain.UlogPreview -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException - -/** - * Writes inbound .ulg payloads into `filesDir/inbox/current.ulg` and bumps the - * `.ready` sentinel so the native poll loop picks them up. The sentinel stores a - * monotonic-millis token (read by the C side as content, not stat-mtime) so two - * intents in the same wall-clock second are still distinguishable. - */ -class AndroidUlogInboxDataSource( - private val contentResolver: ContentResolver, - filesDir: File -) : UlogInboxDataSource { - - private val inbox: File = File(filesDir, "inbox") - private val target: File = File(inbox, "current.ulg") - private val tmp: File = File(inbox, "current.ulg.tmp") - private val ready: File = File(inbox, ".ready") - - override suspend fun preview(uri: String): Result = - withContext(Dispatchers.IO) { - val parsed = Uri.parse(uri) - val displayName = resolveDisplayNameSync(parsed) - val source = parsed.authority?.takeIf { it.isNotBlank() } ?: parsed.path ?: uri - Result.Success(UlogPreview(displayName = displayName, source = source)) - } - - override suspend fun ingest(uri: String): Result = - withContext(Dispatchers.IO) { - runCatching { - inbox.mkdirs() - val parsed = Uri.parse(uri) - val bytes = (contentResolver.openInputStream(parsed) - ?: throw IOException("openInputStream returned null for $uri")).use { input -> - tmp.outputStream().use { output -> input.copyTo(output) } - } - if (!tmp.renameTo(target)) { - tmp.delete() - throw IOException("renameTo $target failed") - } - ready.writeText(System.currentTimeMillis().toString()) - - val displayName = resolveDisplayNameSync(parsed) - Log.i(TAG, "ingested $uri ($bytes bytes)") - UlogFile(displayName = displayName, sizeBytes = bytes) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { e -> - Log.e(TAG, "ingest failed for $uri", e) - Result.Error(classifyIngestFailure(e)) - } - ) - } - - override suspend fun clearInbox(): EmptyResult = - withContext(Dispatchers.IO) { - runCatching { - ready.delete() - target.delete() - tmp.delete() - }.fold( - onSuccess = { Result.Success(Unit) }, - onFailure = { Result.Error(ReplayError.UNKNOWN) } - ) - } - - private fun resolveDisplayNameSync(parsed: Uri): String { - if (parsed.scheme == "content") { - runCatching { - contentResolver.query( - parsed, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (idx >= 0) return cursor.getString(idx) ?: parsed.toString() - } - } - } - } - return parsed.lastPathSegment ?: parsed.toString() - } - - private fun classifyIngestFailure(e: Throwable): ReplayError = when (e) { - is IOException -> if (e.message?.contains("openInputStream") == true) { - ReplayError.OPEN_FAILED - } else { - ReplayError.WRITE_FAILED - } - else -> ReplayError.UNKNOWN - } - - private companion object { - private const val TAG = "Hawkeye" - } -} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryEntryMappers.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryEntryMappers.kt new file mode 100644 index 0000000..5302c97 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryEntryMappers.kt @@ -0,0 +1,19 @@ +package com.px4.hawkeye.feature.replay.data + +import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity +import com.px4.hawkeye.core.domain.LibraryEntry + +internal fun LibraryEntryEntity.toLibraryEntry(): LibraryEntry = LibraryEntry( + id = id, + displayName = displayName, + sizeBytes = sizeBytes, + importedAtMillis = importedAtMillis, +) + +internal fun LibraryEntry.toEntity(fileName: String): LibraryEntryEntity = LibraryEntryEntity( + id = id, + displayName = displayName, + sizeBytes = sizeBytes, + importedAtMillis = importedAtMillis, + fileName = fileName, +) diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStore.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStore.kt new file mode 100644 index 0000000..76c04b5 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStore.kt @@ -0,0 +1,74 @@ +package com.px4.hawkeye.feature.replay.data + +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.Result +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream + +/** + * The library's on-disk side: imported payloads live under `filesDir/library/`, and + * staging copies one into `filesDir/inbox/current.ulg` and bumps the `.ready` sentinel + * the native poll loop reads (a millis token, so two stages in the same wall-clock + * second are still distinguishable). + * + * Pure file/JVM logic with no Android dependencies, so it is unit-testable against a + * temp directory. [clock] is injected for deterministic sentinel tokens in tests. + */ +class LibraryFileStore( + filesDir: File, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val libraryDir = File(filesDir, "library") + private val inboxDir = File(filesDir, "inbox") + + /** Streams [source] into the library under [fileName] (atomic via .tmp + rename). */ + fun write(source: InputStream, fileName: String): Result = runCatching { + libraryDir.mkdirs() + val target = File(libraryDir, fileName) + val tmp = File(libraryDir, "$fileName.tmp") + val bytes = source.use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + if (!tmp.renameTo(target)) { + tmp.delete() + throw IOException("renameTo $target failed") + } + bytes + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Error(classify(it)) }, + ) + + /** Copies the library payload [fileName] into the inbox and bumps the sentinel. */ + fun stage(fileName: String): EmptyResult = runCatching { + val source = File(libraryDir, fileName) + if (!source.exists()) throw FileNotFoundException("missing library file $fileName") + inboxDir.mkdirs() + val target = File(inboxDir, "current.ulg") + val tmp = File(inboxDir, "current.ulg.tmp") + source.inputStream().use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + if (!tmp.renameTo(target)) { + tmp.delete() + throw IOException("renameTo $target failed") + } + File(inboxDir, ".ready").writeText(clock().toString()) + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Error(classify(it)) }, + ) + + /** Removes the library payload [fileName]; no-op if it is already gone. */ + fun delete(fileName: String) { + File(libraryDir, fileName).delete() + } + + private fun classify(e: Throwable): DataError.Local = when { + e is FileNotFoundException -> DataError.Local.NOT_FOUND + e is IOException && e.message?.contains("ENOSPC", ignoreCase = true) == true -> + DataError.Local.DISK_FULL + e is IOException && e.message?.contains("space", ignoreCase = true) == true -> + DataError.Local.DISK_FULL + else -> DataError.Local.UNKNOWN + } +} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/ReplayFileManager.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/ReplayFileManager.kt new file mode 100644 index 0000000..335454c --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/ReplayFileManager.kt @@ -0,0 +1,24 @@ +package com.px4.hawkeye.feature.replay.data + +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.Result + +/** + * Filesystem side of the library that the repository depends on. Takes the document [uri] + * as a String (the Android `Uri` parsing lives in [AndroidReplayFileManager]) so the + * repository stays platform-free and unit-testable with a fake. + */ +interface ReplayFileManager { + /** Streams the document at [uri] into the library under [fileName]; returns bytes. */ + fun import(uri: String, fileName: String): Result + + /** Copies the library payload [fileName] into the renderer inbox and bumps the sentinel. */ + fun stage(fileName: String): EmptyResult + + /** Removes the library payload [fileName]. */ + fun delete(fileName: String) + + /** Human-readable name for [uri] — the provider's DISPLAY_NAME, else the last path segment. */ + fun resolveDisplayName(uri: String): String +} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepository.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepository.kt new file mode 100644 index 0000000..dd3c8b1 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepository.kt @@ -0,0 +1,85 @@ +package com.px4.hawkeye.feature.replay.data + +import android.database.sqlite.SQLiteFullException +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.Result +import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity +import com.px4.hawkeye.feature.replay.data.db.ReplayLibraryDao +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.ReplayLibraryRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.util.UUID + +/** + * Library repository backed by a Room DAO for metadata and [ReplayFileManager] for the + * on-disk payloads. [clock] and [idGenerator] are injected for deterministic tests. + */ +class RoomReplayLibraryRepository( + private val dao: ReplayLibraryDao, + private val fileManager: ReplayFileManager, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val clock: () -> Long = System::currentTimeMillis, + private val idGenerator: () -> String = { UUID.randomUUID().toString() }, +) : ReplayLibraryRepository { + + override fun observeLibrary(): Flow> = + dao.observeAll().map { rows -> rows.map(LibraryEntryEntity::toLibraryEntry) } + + override suspend fun import(uri: String): Result = + withContext(ioDispatcher) { + val id = idGenerator() + val fileName = "$id.ulg" + val displayName = fileManager.resolveDisplayName(uri) + when (val written = fileManager.import(uri, fileName)) { + is Result.Error -> written + is Result.Success -> { + val entry = LibraryEntry( + id = id, + displayName = displayName, + sizeBytes = written.data, + importedAtMillis = clock(), + ) + runCatching { dao.insert(entry.toEntity(fileName)) }.fold( + onSuccess = { Result.Success(entry) }, + onFailure = { + // Don't leave an orphaned payload if the metadata row fails. + fileManager.delete(fileName) + Result.Error(it.toLocalError()) + }, + ) + } + } + } + + override suspend fun delete(id: String): EmptyResult = + withContext(ioDispatcher) { + runCatching { + val row = dao.getById(id) + if (row == null) { + Result.Error(DataError.Local.NOT_FOUND) + } else { + fileManager.delete(row.fileName) + dao.deleteById(id) + Result.Success(Unit) + } + }.getOrElse { Result.Error(it.toLocalError()) } + } + + override suspend fun stageForPlayback(id: String): EmptyResult = + withContext(ioDispatcher) { + runCatching { + val row = dao.getById(id) + if (row == null) Result.Error(DataError.Local.NOT_FOUND) else fileManager.stage(row.fileName) + }.getOrElse { Result.Error(it.toLocalError()) } + } + + private fun Throwable.toLocalError(): DataError.Local = when (this) { + is SQLiteFullException -> DataError.Local.DISK_FULL + else -> DataError.Local.UNKNOWN + } +} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/LibraryEntryEntity.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/LibraryEntryEntity.kt new file mode 100644 index 0000000..76a8501 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/LibraryEntryEntity.kt @@ -0,0 +1,19 @@ +package com.px4.hawkeye.feature.replay.data.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Room row for one imported log. [fileName] is the payload's name under + * `filesDir/library/` — kept on the row so deletion and staging can find the file + * without re-deriving it from the id. + */ +@Entity(tableName = "library_entries") +data class LibraryEntryEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "size_bytes") val sizeBytes: Long, + @ColumnInfo(name = "imported_at_millis") val importedAtMillis: Long, + @ColumnInfo(name = "file_name") val fileName: String, +) diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayDatabase.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayDatabase.kt new file mode 100644 index 0000000..8882699 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayDatabase.kt @@ -0,0 +1,9 @@ +package com.px4.hawkeye.feature.replay.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [LibraryEntryEntity::class], version = 1, exportSchema = true) +abstract class ReplayDatabase : RoomDatabase() { + abstract fun libraryDao(): ReplayLibraryDao +} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayLibraryDao.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayLibraryDao.kt new file mode 100644 index 0000000..1e27c11 --- /dev/null +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/db/ReplayLibraryDao.kt @@ -0,0 +1,23 @@ +package com.px4.hawkeye.feature.replay.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ReplayLibraryDao { + + @Query("SELECT * FROM library_entries ORDER BY imported_at_millis DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM library_entries WHERE id = :id") + suspend fun getById(id: String): LibraryEntryEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: LibraryEntryEntity) + + @Query("DELETE FROM library_entries WHERE id = :id") + suspend fun deleteById(id: String) +} diff --git a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/di/ReplayDataModule.kt b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/di/ReplayDataModule.kt index 952af02..79749e9 100644 --- a/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/di/ReplayDataModule.kt +++ b/android/feature/replay/data/src/main/kotlin/com/px4/hawkeye/feature/replay/data/di/ReplayDataModule.kt @@ -1,15 +1,25 @@ package com.px4.hawkeye.feature.replay.data.di -import com.px4.hawkeye.feature.replay.data.AndroidUlogInboxDataSource -import com.px4.hawkeye.feature.replay.domain.UlogInboxDataSource +import androidx.room.Room +import com.px4.hawkeye.feature.replay.data.AndroidReplayFileManager +import com.px4.hawkeye.feature.replay.data.LibraryFileStore +import com.px4.hawkeye.feature.replay.data.ReplayFileManager +import com.px4.hawkeye.feature.replay.data.RoomReplayLibraryRepository +import com.px4.hawkeye.feature.replay.data.db.ReplayDatabase +import com.px4.hawkeye.core.domain.ReplayLibraryRepository import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val replayDataModule = module { - single { - AndroidUlogInboxDataSource( - contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir - ) + single { + Room.databaseBuilder( + androidContext(), + ReplayDatabase::class.java, + "replay_library.db", + ).build() } + single { get().libraryDao() } + single { LibraryFileStore(androidContext().filesDir) } + single { AndroidReplayFileManager(androidContext().contentResolver, get()) } + single { RoomReplayLibraryRepository(get(), get()) } } diff --git a/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayFileManager.kt b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayFileManager.kt new file mode 100644 index 0000000..800abcd --- /dev/null +++ b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayFileManager.kt @@ -0,0 +1,29 @@ +package com.px4.hawkeye.feature.replay.data + +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.Result + +class FakeReplayFileManager : ReplayFileManager { + var importResult: Result = Result.Success(2048L) + var stageResult: EmptyResult = Result.Success(Unit) + var displayName: String = "flight.ulg" + + val importedFileNames = mutableListOf() + val deletedFileNames = mutableListOf() + val stagedFileNames = mutableListOf() + + override fun import(uri: String, fileName: String): Result { + importedFileNames += fileName + return importResult + } + + override fun stage(fileName: String): EmptyResult { + stagedFileNames += fileName + return stageResult + } + + override fun delete(fileName: String) { deletedFileNames += fileName } + + override fun resolveDisplayName(uri: String): String = displayName +} diff --git a/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayLibraryDao.kt b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayLibraryDao.kt new file mode 100644 index 0000000..24df038 --- /dev/null +++ b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/FakeReplayLibraryDao.kt @@ -0,0 +1,29 @@ +package com.px4.hawkeye.feature.replay.data + +import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity +import com.px4.hawkeye.feature.replay.data.db.ReplayLibraryDao +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** In-memory [ReplayLibraryDao] that mirrors the real `ORDER BY imported_at_millis DESC`. */ +class FakeReplayLibraryDao : ReplayLibraryDao { + private val rows = MutableStateFlow>(emptyList()) + var insertShouldThrow: Throwable? = null + + fun seed(vararg entities: LibraryEntryEntity) { rows.value = entities.toList() } + + override fun observeAll(): Flow> = + rows.map { list -> list.sortedByDescending { it.importedAtMillis } } + + override suspend fun getById(id: String): LibraryEntryEntity? = rows.value.find { it.id == id } + + override suspend fun insert(entity: LibraryEntryEntity) { + insertShouldThrow?.let { throw it } + rows.value = rows.value.filterNot { it.id == entity.id } + entity + } + + override suspend fun deleteById(id: String) { + rows.value = rows.value.filterNot { it.id == id } + } +} diff --git a/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStoreTest.kt b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStoreTest.kt new file mode 100644 index 0000000..eafdc6b --- /dev/null +++ b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/LibraryFileStoreTest.kt @@ -0,0 +1,76 @@ +package com.px4.hawkeye.feature.replay.data + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.Result +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.File + +class LibraryFileStoreTest { + + private fun store(dir: File, now: Long = 1_000L) = LibraryFileStore(dir, clock = { now }) + + @Test + fun `write copies bytes into the library and returns the size`(@TempDir dir: File) { + val bytes = "hello ulog".toByteArray() + + val result = store(dir).write(ByteArrayInputStream(bytes), "abc.ulg") + + assertThat(result).isEqualTo(Result.Success(bytes.size.toLong())) + val written = File(File(dir, "library"), "abc.ulg") + assertThat(written.exists()).isTrue() + assertThat(written.readBytes().toList()).isEqualTo(bytes.toList()) + } + + @Test + fun `stage copies the library file into the inbox and writes the millis token`(@TempDir dir: File) { + val store = store(dir, now = 4_242L) + val bytes = "payload".toByteArray() + store.write(ByteArrayInputStream(bytes), "abc.ulg") + + val result = store.stage("abc.ulg") + + assertThat(result).isEqualTo(Result.Success(Unit)) + val current = File(File(dir, "inbox"), "current.ulg") + val ready = File(File(dir, "inbox"), ".ready") + assertThat(current.readBytes().toList()).isEqualTo(bytes.toList()) + assertThat(ready.readText()).isEqualTo("4242") + } + + @Test + fun `re-staging overwrites the inbox file`(@TempDir dir: File) { + val store = store(dir) + store.write(ByteArrayInputStream("first".toByteArray()), "a.ulg") + store.write(ByteArrayInputStream("second".toByteArray()), "b.ulg") + + store.stage("a.ulg") + store.stage("b.ulg") + + val current = File(File(dir, "inbox"), "current.ulg") + assertThat(current.readText()).isEqualTo("second") + } + + @Test + fun `staging a missing file fails with NOT_FOUND`(@TempDir dir: File) { + val result = store(dir).stage("ghost.ulg") + + assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + } + + @Test + fun `delete removes the library file`(@TempDir dir: File) { + val store = store(dir) + store.write(ByteArrayInputStream("x".toByteArray()), "a.ulg") + val file = File(File(dir, "library"), "a.ulg") + assertThat(file.exists()).isTrue() + + store.delete("a.ulg") + + assertThat(file.exists()).isFalse() + } +} diff --git a/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepositoryTest.kt b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepositoryTest.kt new file mode 100644 index 0000000..fd9c6eb --- /dev/null +++ b/android/feature/replay/data/src/test/kotlin/com/px4/hawkeye/feature/replay/data/RoomReplayLibraryRepositoryTest.kt @@ -0,0 +1,129 @@ +package com.px4.hawkeye.feature.replay.data + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.Result +import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RoomReplayLibraryRepositoryTest { + + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var dao: FakeReplayLibraryDao + private lateinit var files: FakeReplayFileManager + + @BeforeEach fun setUp() { + dao = FakeReplayLibraryDao() + files = FakeReplayFileManager() + } + + private fun repository() = RoomReplayLibraryRepository( + dao = dao, + fileManager = files, + ioDispatcher = dispatcher, + clock = { 1_000L }, + idGenerator = { "fixed-id" }, + ) + + @Test + fun `import writes the payload and inserts a metadata row`() = runTest { + files.importResult = Result.Success(4096L) + files.displayName = "log.ulg" + + val result = repository().import("content://doc") + + assertThat(result).isEqualTo( + Result.Success(LibraryEntry("fixed-id", "log.ulg", 4096L, 1_000L)), + ) + assertThat(files.importedFileNames).containsExactly("fixed-id.ulg") + assertThat(dao.getById("fixed-id")).isNotNull() + } + + @Test + fun `import returns the write error and inserts nothing`() = runTest { + files.importResult = Result.Error(DataError.Local.DISK_FULL) + + val result = repository().import("content://doc") + + assertThat(result).isEqualTo(Result.Error(DataError.Local.DISK_FULL)) + assertThat(dao.getById("fixed-id")).isNull() + } + + @Test + fun `import maps a DAO failure to an error and removes the orphaned payload`() = runTest { + dao.insertShouldThrow = RuntimeException("db locked") + + val result = repository().import("content://doc") + + assertThat(result).isEqualTo(Result.Error(DataError.Local.UNKNOWN)) + assertThat(files.deletedFileNames).containsExactly("fixed-id.ulg") + } + + @Test + fun `observeLibrary maps rows newest first`() = runTest { + dao.seed( + entity("a", importedAt = 10L), + entity("b", importedAt = 30L), + entity("c", importedAt = 20L), + ) + + val entries = repository().observeLibrary().first() + + assertThat(entries.map { it.id }).containsExactly("b", "c", "a") + } + + @Test + fun `delete removes the payload and the row`() = runTest { + dao.seed(entity("a")) + + val result = repository().delete("a") + + assertThat(result).isEqualTo(Result.Success(Unit)) + assertThat(files.deletedFileNames).containsExactly("a.ulg") + assertThat(dao.getById("a")).isNull() + } + + @Test + fun `delete returns NOT_FOUND for an unknown id`() = runTest { + val result = repository().delete("missing") + + assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + assertThat(files.deletedFileNames).isEqualTo(emptyList()) + } + + @Test + fun `stageForPlayback stages the entry's payload`() = runTest { + dao.seed(entity("a")) + + val result = repository().stageForPlayback("a") + + assertThat(result).isEqualTo(Result.Success(Unit)) + assertThat(files.stagedFileNames).containsExactly("a.ulg") + } + + @Test + fun `stageForPlayback returns NOT_FOUND for an unknown id`() = runTest { + val result = repository().stageForPlayback("missing") + + assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + } + + private fun entity(id: String, importedAt: Long = 0L) = LibraryEntryEntity( + id = id, + displayName = "$id.ulg", + sizeBytes = 1L, + importedAtMillis = importedAt, + fileName = "$id.ulg", + ) +} diff --git a/android/feature/replay/domain/build.gradle.kts b/android/feature/replay/domain/build.gradle.kts deleted file mode 100644 index af7c7f5..0000000 --- a/android/feature/replay/domain/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - id("hawkeye.jvm.library") -} - -dependencies { - api(project(":core:domain")) -} diff --git a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/ReplayError.kt b/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/ReplayError.kt deleted file mode 100644 index 42405cf..0000000 --- a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/ReplayError.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.px4.hawkeye.feature.replay.domain - -import com.px4.hawkeye.core.domain.Error - -enum class ReplayError : Error { - OPEN_FAILED, - WRITE_FAILED, - UNKNOWN -} diff --git a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogFile.kt b/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogFile.kt deleted file mode 100644 index 6c16611..0000000 --- a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogFile.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.px4.hawkeye.feature.replay.domain - -data class UlogFile( - val displayName: String, - val sizeBytes: Long -) diff --git a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogInboxDataSource.kt b/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogInboxDataSource.kt deleted file mode 100644 index 47efa51..0000000 --- a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogInboxDataSource.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.px4.hawkeye.feature.replay.domain - -import com.px4.hawkeye.core.domain.EmptyResult -import com.px4.hawkeye.core.domain.Result - -interface UlogInboxDataSource { - - /** - * Resolves the user-facing display name and a short "source" string (authority or path) - * for an inbound .ulg URI. Used to populate the confirm-open dialog before ingestion. - */ - suspend fun preview(uri: String): Result - - /** - * Copies the URI's bytes into the inbox via a .tmp + atomic rename, then bumps - * the .ready sentinel that the native poll loop watches. - */ - suspend fun ingest(uri: String): Result - - /** - * Removes any previously-ingested payload and the sentinel. Used on cold launch - * with no inbound intent so the native side starts at origin. - */ - suspend fun clearInbox(): EmptyResult -} diff --git a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogPreview.kt b/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogPreview.kt deleted file mode 100644 index 01c11fc..0000000 --- a/android/feature/replay/domain/src/main/kotlin/com/px4/hawkeye/feature/replay/domain/UlogPreview.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.px4.hawkeye.feature.replay.domain - -data class UlogPreview( - val displayName: String, - val source: String -) diff --git a/android/feature/replay/presentation/build.gradle.kts b/android/feature/replay/presentation/build.gradle.kts index 766a434..aec952d 100644 --- a/android/feature/replay/presentation/build.gradle.kts +++ b/android/feature/replay/presentation/build.gradle.kts @@ -11,5 +11,5 @@ dependencies { implementation(project(":core:domain")) implementation(project(":core:presentation")) implementation(project(":core:design-system")) - implementation(project(":feature:replay:domain")) + implementation(project(":core:navigation")) } diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapper.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapper.kt new file mode 100644 index 0000000..4ca47c1 --- /dev/null +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapper.kt @@ -0,0 +1,35 @@ +package com.px4.hawkeye.feature.replay.presentation + +import com.px4.hawkeye.core.domain.LibraryEntry +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +internal fun LibraryEntry.toUi(): LibraryEntryUi = LibraryEntryUi( + id = id, + displayName = displayName, + sizeLabel = formatSize(sizeBytes), + importedLabel = formatImported(importedAtMillis), +) + +private const val UNIT_STEP = 1024.0 + +/** Human-readable byte size, e.g. "12.4 MB". Locale.US for a stable, technical label. */ +internal fun formatSize(bytes: Long): String { + val kb = UNIT_STEP + val mb = kb * UNIT_STEP + val gb = mb * UNIT_STEP + return when { + bytes >= gb -> String.format(Locale.US, "%.1f GB", bytes / gb) + bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb) + bytes >= kb -> String.format(Locale.US, "%.1f KB", bytes / kb) + else -> "$bytes B" + } +} + +private val importedFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneId.systemDefault()) + +internal fun formatImported(millis: Long): String = + importedFormatter.format(Instant.ofEpochMilli(millis)) diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayAction.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayAction.kt deleted file mode 100644 index 8915e7e..0000000 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayAction.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -sealed interface ReplayAction { - /** - * Fired by `HawkeyeActivity` on cold launch. [fromFreshIngest] is true only when - * the activity was started by `IntentRouterActivity` right after it ingested a - * new `.ulg` into the inbox — in that case the inbox holds a file the user - * explicitly asked for, so we leave it alone and the native poll loop picks it up. - * - * Otherwise (user tapped the app icon, no fresh share), the inbox may still hold - * a stale `current.ulg` from a previous session. We wipe it so the renderer - * starts on the empty-state HUD and surface the "No file loaded" dialog. - */ - data class OnAppStarted(val fromFreshIngest: Boolean) : ReplayAction - - /** - * Fired by `IntentRouterActivity` when an inbound VIEW/SEND intent arrives. - * Triggers preview resolution and shows the "Open ULog?" confirm dialog. - */ - data class OnIntentReceived(val uri: String) : ReplayAction - - data object OnConfirmOpen : ReplayAction - data object OnDismissDialog : ReplayAction -} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayErrorToUiText.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayErrorToUiText.kt deleted file mode 100644 index d0919c9..0000000 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayErrorToUiText.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -import com.px4.hawkeye.core.presentation.UiText -import com.px4.hawkeye.feature.replay.domain.ReplayError - -fun ReplayError.toUiText(): UiText = when (this) { - ReplayError.OPEN_FAILED -> UiText.StringResource(R.string.replay_error_open_failed) - ReplayError.WRITE_FAILED -> UiText.StringResource(R.string.replay_error_write_failed) - ReplayError.UNKNOWN -> UiText.StringResource(R.string.replay_error_unknown) -} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayEvent.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayEvent.kt deleted file mode 100644 index e42cabc..0000000 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayEvent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -import com.px4.hawkeye.core.presentation.UiText - -sealed interface ReplayEvent { - data class ShowToast(val text: UiText) : ReplayEvent -} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryAction.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryAction.kt new file mode 100644 index 0000000..6257193 --- /dev/null +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryAction.kt @@ -0,0 +1,10 @@ +package com.px4.hawkeye.feature.replay.presentation + +sealed interface ReplayLibraryAction { + data object OnOpenFileClicked : ReplayLibraryAction + data class OnFilePicked(val uri: String?) : ReplayLibraryAction + data class OnEntryClicked(val id: String) : ReplayLibraryAction + data class OnDeleteRequested(val id: String) : ReplayLibraryAction + data object OnConfirmDelete : ReplayLibraryAction + data object OnDismissDelete : ReplayLibraryAction +} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryEvent.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryEvent.kt new file mode 100644 index 0000000..05f9f81 --- /dev/null +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryEvent.kt @@ -0,0 +1,13 @@ +package com.px4.hawkeye.feature.replay.presentation + +import com.px4.hawkeye.core.presentation.UiText + +sealed interface ReplayLibraryEvent { + /** Open the system document picker. */ + data object LaunchFilePicker : ReplayLibraryEvent + + /** A log was staged into the inbox; hand off to the renderer. */ + data class LaunchReplay(val entryId: String) : ReplayLibraryEvent + + data class ShowError(val text: UiText) : ReplayLibraryEvent +} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryScreen.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryScreen.kt new file mode 100644 index 0000000..577586f --- /dev/null +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryScreen.kt @@ -0,0 +1,225 @@ +package com.px4.hawkeye.feature.replay.presentation + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import com.px4.hawkeye.core.designsystem.HawkeyeAlpha +import com.px4.hawkeye.core.designsystem.HawkeyeDimens +import com.px4.hawkeye.core.designsystem.HawkeyeTheme +import com.px4.hawkeye.core.presentation.ObserveAsEvents +import com.px4.hawkeye.core.presentation.ReplayPlaybackLauncher +import com.px4.hawkeye.core.presentation.asString +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +@Composable +fun ReplayLibraryRoot( + viewModel: ReplayLibraryViewModel = koinViewModel(), + playbackLauncher: ReplayPlaybackLauncher = koinInject(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + // ULog has no registered MIME, so accept any document and validate on import. + val pickFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + viewModel.onAction(ReplayLibraryAction.OnFilePicked(uri?.toString())) + } + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + ReplayLibraryEvent.LaunchFilePicker -> pickFile.launch(arrayOf("*/*")) + is ReplayLibraryEvent.LaunchReplay -> playbackLauncher.launch(context, event.entryId) + is ReplayLibraryEvent.ShowError -> + Toast.makeText(context, event.text.asString(context), Toast.LENGTH_SHORT).show() + } + } + + ReplayLibraryScreen(state = state, onAction = viewModel::onAction) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReplayLibraryScreen( + state: ReplayLibraryState, + onAction: (ReplayLibraryAction) -> Unit, +) { + Scaffold( + topBar = { TopAppBar(title = { Text(stringResource(R.string.replay_library_title)) }) }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { onAction(ReplayLibraryAction.OnOpenFileClicked) }, + ) { Text(stringResource(R.string.replay_open_file)) } + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + when { + state.isLoading -> + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + + state.entries.isEmpty() -> + EmptyState(modifier = Modifier.align(Alignment.Center)) + + else -> LibraryList(entries = state.entries, onAction = onAction) + } + + if (state.isImporting) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), + ) + } + } + } + + state.pendingDelete?.let { entry -> + DeleteConfirmationDialog( + displayName = entry.displayName, + onConfirm = { onAction(ReplayLibraryAction.OnConfirmDelete) }, + onDismiss = { onAction(ReplayLibraryAction.OnDismissDelete) }, + ) + } +} + +@Composable +private fun LibraryList( + entries: List, + onAction: (ReplayLibraryAction) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = HawkeyeDimens.contentPadding), + ) { + items(items = entries, key = { it.id }) { entry -> + LibraryRow( + entry = entry, + onClick = { onAction(ReplayLibraryAction.OnEntryClicked(entry.id)) }, + onLongClick = { onAction(ReplayLibraryAction.OnDeleteRequested(entry.id)) }, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LibraryRow( + entry: LibraryEntryUi, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding( + horizontal = HawkeyeDimens.contentPadding, + vertical = HawkeyeDimens.itemSpacing, + ), + ) { + Text( + text = entry.displayName, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.replay_entry_meta, entry.sizeLabel, entry.importedLabel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HawkeyeAlpha.CARD_CAPTION), + modifier = Modifier.padding(top = HawkeyeDimens.captionSpacing), + ) + } +} + +@Composable +private fun EmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(HawkeyeDimens.screenPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(HawkeyeDimens.titleSpacing), + ) { + Text( + text = stringResource(R.string.replay_empty_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.replay_empty_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HawkeyeAlpha.CARD_CAPTION), + ) + } +} + +@Composable +private fun DeleteConfirmationDialog( + displayName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.replay_delete_title)) }, + text = { Text(stringResource(R.string.replay_delete_message, displayName)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.replay_delete_confirm)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.replay_delete_cancel)) } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ReplayLibraryScreenPreview() { + HawkeyeTheme { + ReplayLibraryScreen( + state = ReplayLibraryState( + isLoading = false, + entries = listOf( + LibraryEntryUi("1", "flight_2026_05_28.ulg", "12.4 MB", "May 28, 2026"), + LibraryEntryUi("2", "sitl_test.ulg", "3.1 MB", "May 27, 2026"), + ), + ), + onAction = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ReplayLibraryEmptyPreview() { + HawkeyeTheme { + ReplayLibraryScreen( + state = ReplayLibraryState(isLoading = false), + onAction = {}, + ) + } +} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryState.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryState.kt new file mode 100644 index 0000000..464c050 --- /dev/null +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryState.kt @@ -0,0 +1,19 @@ +package com.px4.hawkeye.feature.replay.presentation + +import androidx.compose.runtime.Stable + +@Stable +data class ReplayLibraryState( + val entries: List = emptyList(), + val isLoading: Boolean = true, + val isImporting: Boolean = false, + val pendingDelete: LibraryEntryUi? = null, +) + +/** Presentation view of a library entry, with size/date already formatted for display. */ +data class LibraryEntryUi( + val id: String, + val displayName: String, + val sizeLabel: String, + val importedLabel: String, +) diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModel.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModel.kt new file mode 100644 index 0000000..7c88bf1 --- /dev/null +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModel.kt @@ -0,0 +1,77 @@ +package com.px4.hawkeye.feature.replay.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.px4.hawkeye.core.domain.onFailure +import com.px4.hawkeye.core.domain.onSuccess +import com.px4.hawkeye.core.presentation.toUiText +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.ReplayLibraryRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ReplayLibraryViewModel( + private val repository: ReplayLibraryRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(ReplayLibraryState()) + val state = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + init { + viewModelScope.launch { + repository.observeLibrary().collect { entries -> + _state.update { it.copy(entries = entries.map(LibraryEntry::toUi), isLoading = false) } + } + } + } + + fun onAction(action: ReplayLibraryAction) { + when (action) { + ReplayLibraryAction.OnOpenFileClicked -> + viewModelScope.launch { _events.send(ReplayLibraryEvent.LaunchFilePicker) } + + is ReplayLibraryAction.OnFilePicked -> action.uri?.let(::importFile) + + is ReplayLibraryAction.OnEntryClicked -> stageAndLaunch(action.id) + + is ReplayLibraryAction.OnDeleteRequested -> + _state.update { state -> state.copy(pendingDelete = state.entries.find { it.id == action.id }) } + + ReplayLibraryAction.OnConfirmDelete -> confirmDelete() + + ReplayLibraryAction.OnDismissDelete -> + _state.update { it.copy(pendingDelete = null) } + } + } + + private fun importFile(uri: String) { + viewModelScope.launch { + _state.update { it.copy(isImporting = true) } + repository.import(uri).onFailure { _events.send(ReplayLibraryEvent.ShowError(it.toUiText())) } + _state.update { it.copy(isImporting = false) } + } + } + + private fun stageAndLaunch(id: String) { + viewModelScope.launch { + repository.stageForPlayback(id) + .onSuccess { _events.send(ReplayLibraryEvent.LaunchReplay(id)) } + .onFailure { _events.send(ReplayLibraryEvent.ShowError(it.toUiText())) } + } + } + + private fun confirmDelete() { + val pending = _state.value.pendingDelete ?: return + _state.update { it.copy(pendingDelete = null) } + viewModelScope.launch { + repository.delete(pending.id).onFailure { _events.send(ReplayLibraryEvent.ShowError(it.toUiText())) } + } + } +} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayScreen.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayScreen.kt deleted file mode 100644 index ae68959..0000000 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayScreen.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -import android.widget.Toast -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.px4.hawkeye.core.designsystem.HawkeyeTheme -import com.px4.hawkeye.core.presentation.ObserveAsEvents -import com.px4.hawkeye.core.presentation.asString -import org.koin.androidx.compose.koinViewModel - -@Composable -fun ReplayRoot(viewModel: ReplayViewModel = koinViewModel()) { - val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - - ObserveAsEvents(viewModel.events) { event -> - when (event) { - is ReplayEvent.ShowToast -> - Toast.makeText(context, event.text.asString(context), Toast.LENGTH_SHORT).show() - } - } - - ReplayScreen(state = state, onAction = viewModel::onAction) -} - -@Composable -fun ReplayScreen( - state: ReplayState, - onAction: (ReplayAction) -> Unit -) { - when (val dialog = state.dialog) { - is ReplayDialog.ConfirmOpen -> ConfirmOpenDialog(dialog, onAction) - ReplayDialog.NoFileLoaded -> NoFileLoadedDialog(onAction) - null -> Unit - } -} - -@Composable -private fun ConfirmOpenDialog( - dialog: ReplayDialog.ConfirmOpen, - onAction: (ReplayAction) -> Unit -) { - AlertDialog( - onDismissRequest = { onAction(ReplayAction.OnDismissDialog) }, - title = { Text(stringResource(R.string.replay_prompt_open_title)) }, - text = { - Text( - text = stringResource( - R.string.replay_prompt_open_message, - dialog.displayName, - dialog.source - ) - ) - }, - confirmButton = { - TextButton(onClick = { onAction(ReplayAction.OnConfirmOpen) }) { - Text(stringResource(R.string.replay_prompt_open)) - } - }, - dismissButton = { - TextButton(onClick = { onAction(ReplayAction.OnDismissDialog) }) { - Text(stringResource(R.string.replay_prompt_cancel)) - } - } - ) -} - -@Composable -private fun NoFileLoadedDialog(onAction: (ReplayAction) -> Unit) { - AlertDialog( - onDismissRequest = { onAction(ReplayAction.OnDismissDialog) }, - title = { Text(stringResource(R.string.replay_prompt_no_file_title)) }, - text = { Text(stringResource(R.string.replay_prompt_no_file_message)) }, - confirmButton = { - TextButton(onClick = { onAction(ReplayAction.OnDismissDialog) }) { - Text(stringResource(R.string.replay_prompt_ok)) - } - } - ) -} - -@Preview -@Composable -private fun ConfirmOpenDialogPreview() { - HawkeyeTheme { - ReplayScreen( - state = ReplayState( - dialog = ReplayDialog.ConfirmOpen( - displayName = "flight_2025_05_21.ulg", - source = "com.google.android.apps.docs.storage" - ) - ), - onAction = {} - ) - } -} - -@Preview -@Composable -private fun NoFileLoadedDialogPreview() { - HawkeyeTheme { - ReplayScreen( - state = ReplayState(dialog = ReplayDialog.NoFileLoaded), - onAction = {} - ) - } -} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayState.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayState.kt deleted file mode 100644 index 9686b7c..0000000 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -data class ReplayState( - val dialog: ReplayDialog? = null, - val isIngesting: Boolean = false -) - -sealed interface ReplayDialog { - data class ConfirmOpen( - val displayName: String, - val source: String - ) : ReplayDialog - - data object NoFileLoaded : ReplayDialog -} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModel.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModel.kt deleted file mode 100644 index dd1fef3..0000000 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.px4.hawkeye.core.domain.onFailure -import com.px4.hawkeye.core.domain.onSuccess -import com.px4.hawkeye.core.presentation.UiText -import com.px4.hawkeye.feature.replay.domain.UlogInboxDataSource -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class ReplayViewModel( - private val ulogInbox: UlogInboxDataSource -) : ViewModel() { - - private val _state = MutableStateFlow(ReplayState()) - val state = _state.asStateFlow() - - // Buffered (not rendezvous) so emitting an event never blocks ingest/preview - // coroutines waiting for a collector — `ObserveAsEvents` stops collecting when - // the lifecycle drops below STARTED, and a rendezvous `send` would suspend - // there, leaving e.g. `isIngesting = true` until the user returns to the app. - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() - - private var pendingUri: String? = null - private var previewJob: Job? = null - - fun onAction(action: ReplayAction) { - when (action) { - is ReplayAction.OnAppStarted -> handleAppStarted(action.fromFreshIngest) - is ReplayAction.OnIntentReceived -> handleIntent(action.uri) - ReplayAction.OnConfirmOpen -> ingestPendingUri() - ReplayAction.OnDismissDialog -> dismissDialog() - } - } - - private fun handleAppStarted(fromFreshIngest: Boolean) { - if (fromFreshIngest) { - // The trampoline just wrote a file the user explicitly asked for; leave - // the inbox alone so the native poll loop picks it up. - return - } - // Plain cold launch (e.g., user tapped the app icon). Don't keep replaying a - // .ulg from a previous session — wipe the inbox and show the empty-state dialog. - viewModelScope.launch { - ulogInbox.clearInbox() - _state.update { it.copy(dialog = ReplayDialog.NoFileLoaded) } - } - } - - private fun handleIntent(uri: String) { - previewJob?.cancel() - previewJob = viewModelScope.launch { - ulogInbox.preview(uri) - .onSuccess { preview -> - pendingUri = uri - _state.update { - it.copy( - dialog = ReplayDialog.ConfirmOpen( - displayName = preview.displayName, - source = preview.source - ) - ) - } - } - .onFailure { error -> - _events.send(ReplayEvent.ShowToast(error.toUiText())) - } - } - } - - private fun ingestPendingUri() { - val uri = pendingUri ?: return - _state.update { it.copy(dialog = null) } - viewModelScope.launch { - _state.update { it.copy(isIngesting = true) } - ulogInbox.ingest(uri) - .onSuccess { file -> - _events.send( - ReplayEvent.ShowToast( - UiText.StringResource( - id = R.string.replay_ingest_success, - args = arrayOf(file.sizeBytes) - ) - ) - ) - } - .onFailure { error -> - _events.send(ReplayEvent.ShowToast(error.toUiText())) - } - _state.update { it.copy(isIngesting = false) } - pendingUri = null - } - } - - private fun dismissDialog() { - previewJob?.cancel() - pendingUri = null - _state.update { it.copy(dialog = null) } - } -} diff --git a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/di/ReplayPresentationModule.kt b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/di/ReplayPresentationModule.kt index 716ea81..d9ed94b 100644 --- a/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/di/ReplayPresentationModule.kt +++ b/android/feature/replay/presentation/src/main/kotlin/com/px4/hawkeye/feature/replay/presentation/di/ReplayPresentationModule.kt @@ -1,9 +1,16 @@ package com.px4.hawkeye.feature.replay.presentation.di -import com.px4.hawkeye.feature.replay.presentation.ReplayViewModel +import com.px4.hawkeye.core.navigation.EntryProviderInstaller +import com.px4.hawkeye.core.navigation.ReplayKey +import com.px4.hawkeye.feature.replay.presentation.ReplayLibraryRoot +import com.px4.hawkeye.feature.replay.presentation.ReplayLibraryViewModel import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named import org.koin.dsl.module val replayPresentationModule = module { - viewModelOf(::ReplayViewModel) + viewModelOf(::ReplayLibraryViewModel) + single(named("replay")) { + { addEntryProvider(ReplayKey::class, { it.toString() }) { ReplayLibraryRoot() } } + } } diff --git a/android/feature/replay/presentation/src/main/res/values/strings.xml b/android/feature/replay/presentation/src/main/res/values/strings.xml index 53c27fd..572122e 100644 --- a/android/feature/replay/presentation/src/main/res/values/strings.xml +++ b/android/feature/replay/presentation/src/main/res/values/strings.xml @@ -1,17 +1,16 @@ - Open ULog? - Open - Cancel - From: %1$s - - %1$s\nFrom: %2$s - No file loaded - The application will do nothing. Share or open a .ulg file from another app to start a replay. - OK - Loaded ULog (%1$d bytes) - Could not open the shared file. - Could not save the file. Try freeing up some storage. - Could not load the file. + Replay library + Open file + + No logs yet + Import a .ulg flight log to replay it. Logs you import are kept here. + + + %1$s · %2$s + + Delete log? + Remove \"%1$s\" from the library? This cannot be undone. + Delete + Cancel diff --git a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeReplayLibraryRepository.kt b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeReplayLibraryRepository.kt new file mode 100644 index 0000000..a484eeb --- /dev/null +++ b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeReplayLibraryRepository.kt @@ -0,0 +1,42 @@ +package com.px4.hawkeye.feature.replay.presentation + +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.EmptyResult +import com.px4.hawkeye.core.domain.Result +import com.px4.hawkeye.core.domain.LibraryEntry +import com.px4.hawkeye.core.domain.ReplayLibraryRepository +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeReplayLibraryRepository : ReplayLibraryRepository { + + val entriesFlow = MutableStateFlow>(emptyList()) + + var importResult: Result = + Result.Success(LibraryEntry("new", "new.ulg", 10L, 0L)) + var stageResult: EmptyResult = Result.Success(Unit) + var deleteResult: EmptyResult = Result.Success(Unit) + + val importedUris = mutableListOf() + val stagedIds = mutableListOf() + val deletedIds = mutableListOf() + + override fun observeLibrary() = entriesFlow + + override suspend fun import(uri: String): Result { + importedUris += uri + return importResult + } + + override suspend fun delete(id: String): EmptyResult { + deletedIds += id + if (deleteResult is Result.Success) { + entriesFlow.value = entriesFlow.value.filterNot { it.id == id } + } + return deleteResult + } + + override suspend fun stageForPlayback(id: String): EmptyResult { + stagedIds += id + return stageResult + } +} diff --git a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeUlogInboxDataSource.kt b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeUlogInboxDataSource.kt deleted file mode 100644 index a474bee..0000000 --- a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/FakeUlogInboxDataSource.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -import com.px4.hawkeye.core.domain.EmptyResult -import com.px4.hawkeye.core.domain.Result -import com.px4.hawkeye.feature.replay.domain.ReplayError -import com.px4.hawkeye.feature.replay.domain.UlogFile -import com.px4.hawkeye.feature.replay.domain.UlogInboxDataSource -import com.px4.hawkeye.feature.replay.domain.UlogPreview - -class FakeUlogInboxDataSource : UlogInboxDataSource { - - var previewResult: Result = - Result.Success(UlogPreview(displayName = "flight.ulg", source = "fakeauthority")) - - var ingestResult: Result = - Result.Success(UlogFile(displayName = "flight.ulg", sizeBytes = 1024L)) - - var clearInboxCount: Int = 0 - val ingestedUris: MutableList = mutableListOf() - val previewedUris: MutableList = mutableListOf() - - override suspend fun preview(uri: String): Result { - previewedUris += uri - return previewResult - } - - override suspend fun ingest(uri: String): Result { - ingestedUris += uri - return ingestResult - } - - override suspend fun clearInbox(): EmptyResult { - clearInboxCount += 1 - return Result.Success(Unit) - } -} diff --git a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapperTest.kt b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapperTest.kt new file mode 100644 index 0000000..735039b --- /dev/null +++ b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/LibraryEntryUiMapperTest.kt @@ -0,0 +1,28 @@ +package com.px4.hawkeye.feature.replay.presentation + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.matches +import com.px4.hawkeye.core.domain.LibraryEntry +import org.junit.jupiter.api.Test + +class LibraryEntryUiMapperTest { + + @Test + fun `formatSize renders bytes, KB, and MB`() { + assertThat(formatSize(0L)).isEqualTo("0 B") + assertThat(formatSize(512L)).isEqualTo("512 B") + assertThat(formatSize(1024L)).isEqualTo("1.0 KB") + assertThat(formatSize(1536L)).isEqualTo("1.5 KB") + assertThat(formatSize(5L * 1024 * 1024)).isEqualTo("5.0 MB") + } + + @Test + fun `toUi formats size and a localized date`() { + // 2026-06-15T12:00:00Z — mid-day so any reasonable time zone still lands in 2026. + val ui = LibraryEntry("1", "flight.ulg", 2L * 1024 * 1024, 1_781_870_400_000L).toUi() + + assertThat(ui.sizeLabel).isEqualTo("2.0 MB") + assertThat(ui.importedLabel).matches(Regex("""[A-Z][a-z]{2} \d{1,2}, \d{4}""")) + } +} diff --git a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModelTest.kt b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModelTest.kt new file mode 100644 index 0000000..ce313d3 --- /dev/null +++ b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayLibraryViewModelTest.kt @@ -0,0 +1,151 @@ +package com.px4.hawkeye.feature.replay.presentation + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import com.px4.hawkeye.core.domain.DataError +import com.px4.hawkeye.core.domain.Result +import com.px4.hawkeye.core.domain.LibraryEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ReplayLibraryViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var repo: FakeReplayLibraryRepository + + @BeforeEach fun setUp() { Dispatchers.setMain(dispatcher); repo = FakeReplayLibraryRepository() } + @AfterEach fun tearDown() { Dispatchers.resetMain() } + + @Test + fun `observing library populates entries and clears loading`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 2048L, 0L)) + val vm = ReplayLibraryViewModel(repo) + + vm.state.test { + val state = awaitItem() + assertThat(state.isLoading).isFalse() + assertThat(state.entries.map { it.id }).containsExactly("1") + } + } + + @Test + fun `observing library lists every entry in repository order`() = runTest { + repo.entriesFlow.value = listOf( + LibraryEntry("1", "newest.ulg", 1L, 30L), + LibraryEntry("2", "middle.ulg", 1L, 20L), + LibraryEntry("3", "oldest.ulg", 1L, 10L), + ) + val vm = ReplayLibraryViewModel(repo) + + vm.state.test { + val entries = awaitItem().entries + assertThat(entries.map { it.id }).containsExactly("1", "2", "3") + assertThat(entries.map { it.displayName }) + .containsExactly("newest.ulg", "middle.ulg", "oldest.ulg") + } + } + + @Test + fun `OnOpenFileClicked emits LaunchFilePicker`() = runTest { + val vm = ReplayLibraryViewModel(repo) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnOpenFileClicked) + assertThat(awaitItem()).isEqualTo(ReplayLibraryEvent.LaunchFilePicker) + } + } + + @Test + fun `OnFilePicked with a uri imports it`() = runTest { + repo.importResult = Result.Success(LibraryEntry("x", "x.ulg", 1L, 0L)) + val vm = ReplayLibraryViewModel(repo) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnFilePicked("content://doc")) + expectNoEvents() + } + assertThat(repo.importedUris).containsExactly("content://doc") + } + + @Test + fun `import failure emits ShowError`() = runTest { + repo.importResult = Result.Error(DataError.Local.DISK_FULL) + val vm = ReplayLibraryViewModel(repo) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnFilePicked("content://doc")) + assertThat(awaitItem()).isInstanceOf(ReplayLibraryEvent.ShowError::class) + } + } + + @Test + fun `OnFilePicked with null uri does nothing`() = runTest { + val vm = ReplayLibraryViewModel(repo) + + vm.onAction(ReplayLibraryAction.OnFilePicked(null)) + + assertThat(repo.importedUris).isEqualTo(emptyList()) + } + + @Test + fun `OnEntryClicked stages then emits LaunchReplay`() = runTest { + val vm = ReplayLibraryViewModel(repo) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnEntryClicked("42")) + assertThat(awaitItem()).isEqualTo(ReplayLibraryEvent.LaunchReplay("42")) + } + assertThat(repo.stagedIds).containsExactly("42") + } + + @Test + fun `stage failure emits ShowError and not LaunchReplay`() = runTest { + repo.stageResult = Result.Error(DataError.Local.NOT_FOUND) + val vm = ReplayLibraryViewModel(repo) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnEntryClicked("42")) + assertThat(awaitItem()).isInstanceOf(ReplayLibraryEvent.ShowError::class) + } + } + + @Test + fun `delete request then confirm deletes and clears the pending entry`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 1L, 0L)) + val vm = ReplayLibraryViewModel(repo) + + vm.onAction(ReplayLibraryAction.OnDeleteRequested("1")) + assertThat(vm.state.value.pendingDelete?.id).isEqualTo("1") + + vm.onAction(ReplayLibraryAction.OnConfirmDelete) + + assertThat(vm.state.value.pendingDelete).isNull() + assertThat(repo.deletedIds).containsExactly("1") + assertThat(vm.state.value.entries).isEqualTo(emptyList()) + } + + @Test + fun `delete request then dismiss does not delete`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 1L, 0L)) + val vm = ReplayLibraryViewModel(repo) + + vm.onAction(ReplayLibraryAction.OnDeleteRequested("1")) + vm.onAction(ReplayLibraryAction.OnDismissDelete) + + assertThat(vm.state.value.pendingDelete).isNull() + assertThat(repo.deletedIds).isEqualTo(emptyList()) + } +} diff --git a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModelTest.kt b/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModelTest.kt deleted file mode 100644 index 17e10a7..0000000 --- a/android/feature/replay/presentation/src/test/kotlin/com/px4/hawkeye/feature/replay/presentation/ReplayViewModelTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.px4.hawkeye.feature.replay.presentation - -import app.cash.turbine.test -import assertk.assertThat -import assertk.assertions.isEqualTo -import assertk.assertions.isInstanceOf -import assertk.assertions.isNull -import com.px4.hawkeye.core.domain.Result -import com.px4.hawkeye.feature.replay.domain.ReplayError -import com.px4.hawkeye.feature.replay.domain.UlogPreview -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class ReplayViewModelTest { - - private val dispatcher = UnconfinedTestDispatcher() - private lateinit var fake: FakeUlogInboxDataSource - private lateinit var viewModel: ReplayViewModel - - @BeforeEach - fun setUp() { - Dispatchers.setMain(dispatcher) - fake = FakeUlogInboxDataSource() - viewModel = ReplayViewModel(fake) - } - - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `OnAppStarted on plain cold launch clears inbox and shows NoFileLoaded`() = runTest { - viewModel.onAction(ReplayAction.OnAppStarted(fromFreshIngest = false)) - - assertThat(fake.clearInboxCount).isEqualTo(1) - assertThat(viewModel.state.value.dialog).isEqualTo(ReplayDialog.NoFileLoaded) - } - - @Test - fun `OnAppStarted from fresh ingest leaves inbox alone and shows no dialog`() = runTest { - viewModel.onAction(ReplayAction.OnAppStarted(fromFreshIngest = true)) - - assertThat(fake.clearInboxCount).isEqualTo(0) - assertThat(viewModel.state.value.dialog).isNull() - } - - @Test - fun `OnIntentReceived resolves preview and shows ConfirmOpen`() = runTest { - fake.previewResult = Result.Success( - UlogPreview(displayName = "flight_2025_05_21.ulg", source = "com.example.docs") - ) - - viewModel.onAction(ReplayAction.OnIntentReceived("content://foo/bar")) - - assertThat(viewModel.state.value.dialog).isEqualTo( - ReplayDialog.ConfirmOpen( - displayName = "flight_2025_05_21.ulg", - source = "com.example.docs" - ) - ) - } - - @Test - fun `second OnIntentReceived replaces the first ConfirmOpen dialog`() = runTest { - fake.previewResult = Result.Success(UlogPreview("first.ulg", "auth-1")) - viewModel.onAction(ReplayAction.OnIntentReceived("content://1")) - - fake.previewResult = Result.Success(UlogPreview("second.ulg", "auth-2")) - viewModel.onAction(ReplayAction.OnIntentReceived("content://2")) - - assertThat(viewModel.state.value.dialog).isEqualTo( - ReplayDialog.ConfirmOpen(displayName = "second.ulg", source = "auth-2") - ) - } - - @Test - fun `OnConfirmOpen triggers ingest, emits success toast, clears dialog`() = runTest { - viewModel.onAction(ReplayAction.OnIntentReceived("content://x")) - - viewModel.events.test { - viewModel.onAction(ReplayAction.OnConfirmOpen) - assertThat(awaitItem()).isInstanceOf(ReplayEvent.ShowToast::class) - } - assertThat(fake.ingestedUris).isEqualTo(listOf("content://x")) - assertThat(viewModel.state.value.dialog).isNull() - } - - @Test - fun `OnConfirmOpen with ingest failure emits error toast and clears dialog`() = runTest { - fake.ingestResult = Result.Error(ReplayError.WRITE_FAILED) - viewModel.onAction(ReplayAction.OnIntentReceived("content://x")) - - viewModel.events.test { - viewModel.onAction(ReplayAction.OnConfirmOpen) - assertThat(awaitItem()).isInstanceOf(ReplayEvent.ShowToast::class) - } - assertThat(viewModel.state.value.dialog).isNull() - } - - @Test - fun `OnDismissDialog clears state and does not ingest`() = runTest { - viewModel.onAction(ReplayAction.OnIntentReceived("content://x")) - - viewModel.onAction(ReplayAction.OnDismissDialog) - - assertThat(viewModel.state.value.dialog).isNull() - assertThat(fake.ingestedUris).isEqualTo(emptyList()) - } - - @Test - fun `preview failure emits error toast and leaves dialog null`() = runTest { - fake.previewResult = Result.Error(ReplayError.OPEN_FAILED) - - viewModel.events.test { - viewModel.onAction(ReplayAction.OnIntentReceived("content://bad")) - assertThat(awaitItem()).isInstanceOf(ReplayEvent.ShowToast::class) - } - assertThat(viewModel.state.value.dialog).isNull() - assertThat(fake.ingestedUris).isEqualTo(emptyList()) - } -} diff --git a/android/gradle.properties b/android/gradle.properties index 34c5e9e..5bb49f2 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -12,4 +12,8 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +# KSP (Room codegen) registers its generated sources via the kotlin.sourceSets DSL, +# which AGP 9's built-in Kotlin support otherwise rejects. This opt-in lets the two +# coexist until KSP wires generated sources through android.sourceSets directly. +android.disallowKotlinSourceSets=false \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 1a05e8f..9510c00 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -22,6 +22,10 @@ datastore = "1.1.1" # Media media3 = "1.10.1" +# Local DB +room = "2.7.1" +ksp = "2.0.21-1.0.28" + # Async kotlinxCoroutines = "1.10.2" @@ -30,8 +34,8 @@ koin = "4.0.0" # Testing junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" +junitVersion = "1.3.0" +espressoCore = "3.7.0" junitJupiter = "5.10.2" turbine = "1.1.0" assertk = "0.28.1" @@ -71,6 +75,11 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +# Room (consumed only by feature:replay:data) +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + # Coroutines kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } @@ -90,6 +99,8 @@ assertk = { group = "com.willowtreeapps.assertk", name = "assertk", version.ref turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } # Gradle plugin classpath (used by build-logic) android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } @@ -103,3 +114,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 129495c..d66af61 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -28,7 +28,6 @@ include(":app") include(":core:domain") include(":core:presentation") include(":core:design-system") -include(":feature:replay:domain") include(":feature:replay:data") include(":feature:replay:presentation") include(":core:navigation") diff --git a/src/hud.c b/src/hud.c index 3e88fb2..ad7557d 100644 --- a/src/hud.c +++ b/src/hud.c @@ -27,6 +27,7 @@ void hud_init(hud_t *h) { memset(h, 0, sizeof(*h)); h->show_help = false; + h->show_transport = true; // platforms that draw their own transport (Android) opt out h->scale_mul = 1.0f; for (int i = 0; i < HUD_MAX_PINNED; i++) h->pinned[i] = -1; @@ -343,7 +344,8 @@ void hud_draw(const hud_t *h, const vehicle_t *vehicles, int primary_h = (int)(120 * s); int secondary_h = (int)(38 * s); bool is_replay_source = sources[selected].playback.duration_s > 0.0f; - int transport_h = is_replay_source ? (int)(28 * s) : 0; + bool show_transport = is_replay_source && h->show_transport; + int transport_h = show_transport ? (int)(28 * s) : 0; int total_bar_h = transport_h + primary_h + (h->pinned_count > 0 ? h->pinned_count * secondary_h + (int)(4 * s) : 0); int bar_y = screen_h - total_bar_h; @@ -453,7 +455,7 @@ void hud_draw(const hud_t *h, const vehicle_t *vehicles, } // Replay transport row (above the main HUD bar) - if (is_replay_source) { + if (show_transport) { hud_draw_transport(h, &sources[selected].playback, sources[selected].connected, &vehicles[selected], markers_all, sys_markers_all, @@ -595,7 +597,7 @@ int hud_bar_height(const hud_t *h, int screen_h) { if (h->scale_mul > 0.0f) s *= h->scale_mul; int primary_h = (int)(120 * s); int secondary_h = (int)(38 * s); - int transport_h = h->is_replay ? (int)(28 * s) : 0; + int transport_h = (h->is_replay && h->show_transport) ? (int)(28 * s) : 0; return transport_h + primary_h + (h->pinned_count > 0 ? h->pinned_count * secondary_h + (int)(4 * s) : 0); } diff --git a/src/hud.h b/src/hud.h index 612fe07..965bf34 100644 --- a/src/hud.h +++ b/src/hud.h @@ -38,6 +38,7 @@ typedef struct { int pinned_count; bool show_help; bool is_replay; // true when data source is ULog replay (affects layout) + bool show_transport; // false hides the transport sub-bar (Android drives transport from a Compose overlay) bool show_yaw; // Y key: swap HDG for YAW display Font font_value; // JetBrains Mono for telemetry numbers Font font_label; // Inter for labels and status text