diff --git a/android/app/src/androidTest/java/com/px4/hawkeye/android/LibrarySwarmSelectionTest.kt b/android/app/src/androidTest/java/com/px4/hawkeye/android/LibrarySwarmSelectionTest.kt new file mode 100644 index 0000000..d2a4240 --- /dev/null +++ b/android/app/src/androidTest/java/com/px4/hawkeye/android/LibrarySwarmSelectionTest.kt @@ -0,0 +1,195 @@ +package com.px4.hawkeye.android + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +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 java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext + +/** + * End-to-end library multi-select on a device: seeds real Room rows plus their on-disk + * payloads, walks the Select -> check -> "Play together" flow through the live UI, and + * verifies the staged inbox the native renderer reads (current.ulg + swarm_1.ulg in click + * order, .ready carrying the batch count). + */ +@RunWith(AndroidJUnit4::class) +class LibrarySwarmSelectionTest { + + @get:Rule + val composeRule = createEmptyComposeRule() + + private val dao: ReplayLibraryDao get() = GlobalContext.get().get() + private val filesDir: File = + ApplicationProvider.getApplicationContext().filesDir + + private val robot by lazy { LibrarySelectionRobot(composeRule) } + + @Before + fun seed() { + runBlocking { + clearLibrary() + val libraryDir = File(filesDir, "library").apply { mkdirs() } + SEED.forEach { (entity, payload) -> + File(libraryDir, entity.fileName).writeText(payload) + dao.insert(entity) + } + } + } + + @After + fun cleanup() { + runBlocking { + clearLibrary() + SEED.forEach { (entity, _) -> File(File(filesDir, "library"), entity.fileName).delete() } + File(filesDir, "inbox").listFiles()?.forEach { it.delete() } + } + } + + private suspend fun clearLibrary() { + dao.observeAll().first().forEach { dao.deleteById(it.id) } + } + + @Test + fun selectionModeShowsCountAndPlayTogetherCta() { + ActivityScenario.launch(MainActivity::class.java).use { + robot + .openLibrary() + .enterSelectionMode() + .clickEntry(ALPHA) + .assertTitle("1 selected") + .clickEntry(BRAVO) + .assertTitle("2 selected") + .assertCta("Play together (2)") + } + } + + @Test + fun playTogetherStagesTheBatchInClickOrder() { + ActivityScenario.launch(MainActivity::class.java).use { + robot + .openLibrary() + .enterSelectionMode() + .clickEntry(BRAVO) // clicked first: becomes drone 0 / current.ulg + .clickEntry(ALPHA) + .clickCta("Play together (2)") + + val inbox = File(filesDir, "inbox") + val ready = File(inbox, ".ready") + composeRule.waitUntil(timeoutMillis = 10_000) { + ready.exists() && ready.readText().endsWith(" 2") + } + assertEquals(BRAVO_PAYLOAD, File(inbox, "current.ulg").readText()) + assertEquals(ALPHA_PAYLOAD, File(inbox, "swarm_1.ulg").readText()) + assertFalse(File(inbox, "swarm_2.ulg").exists()) + assertTrue(ready.readText().endsWith(" 2")) + } + } + + @Test + fun closingSelectionModeRestoresTheLibraryChrome() { + ActivityScenario.launch(MainActivity::class.java).use { + robot + .openLibrary() + .enterSelectionMode() + .clickEntry(ALPHA) + .exitSelectionMode() + .assertTitle("Replay library") + .assertCta("Open file") + } + } + + @Test + fun systemBackExitsSelectionModeBeforeLeavingTheLibrary() { + ActivityScenario.launch(MainActivity::class.java).use { + robot + .openLibrary() + .enterSelectionMode() + .clickEntry(ALPHA) + .pressBack() + .assertTitle("Replay library") + .assertCta("Open file") + } + } + + private companion object { + const val ALPHA = "swarm_alpha.ulg" + const val BRAVO = "swarm_bravo.ulg" + const val CHARLIE = "swarm_charlie.ulg" + const val ALPHA_PAYLOAD = "payload-alpha" + const val BRAVO_PAYLOAD = "payload-bravo" + + // Distinct payloads so the staged-order assertion can tell the files apart. + val SEED = listOf( + LibraryEntryEntity("s1", ALPHA, 13L, 30L, "s1.ulg") to ALPHA_PAYLOAD, + LibraryEntryEntity("s2", BRAVO, 13L, 20L, "s2.ulg") to BRAVO_PAYLOAD, + LibraryEntryEntity("s3", CHARLIE, 13L, 10L, "s3.ulg") to "payload-charlie", + ) + } +} + +private class LibrarySelectionRobot(private val rule: ComposeTestRule) { + + fun openLibrary() = apply { + awaitText("Replay a flight") + rule.onNodeWithText("Replay a flight").performClick() + awaitText("Select") + } + + fun enterSelectionMode() = apply { + rule.onNodeWithText("Select").performClick() + awaitText("0 selected") + } + + fun exitSelectionMode() = apply { + rule.onNodeWithContentDescription("Exit selection").performClick() + } + + fun pressBack() = apply { + rule.waitForIdle() + androidx.test.espresso.Espresso.pressBack() + rule.waitForIdle() + } + + fun clickEntry(displayName: String) = apply { + rule.onNodeWithText(displayName).performClick() + } + + fun clickCta(label: String) = apply { + awaitText(label) + rule.onNodeWithText(label).performClick() + } + + fun assertTitle(title: String) = apply { + rule.onNodeWithText(title).assertIsDisplayed() + } + + fun assertCta(label: String) = apply { + rule.onNodeWithText(label).assertIsDisplayed() + } + + private fun awaitText(text: String) { + rule.waitUntil(timeoutMillis = 5_000) { + rule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/android/app/src/androidTest/java/com/px4/hawkeye/android/SwarmWheelScreenTest.kt b/android/app/src/androidTest/java/com/px4/hawkeye/android/SwarmWheelScreenTest.kt new file mode 100644 index 0000000..dcdf440 --- /dev/null +++ b/android/app/src/androidTest/java/com/px4/hawkeye/android/SwarmWheelScreenTest.kt @@ -0,0 +1,182 @@ +package com.px4.hawkeye.android + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.px4.hawkeye.android.render.SwarmWheelSnapshot +import com.px4.hawkeye.android.render.swarm.DroneWheelItemUi +import com.px4.hawkeye.android.render.swarm.SwarmWheelAction +import com.px4.hawkeye.android.render.swarm.SwarmWheelScreen +import com.px4.hawkeye.android.render.swarm.SwarmWheelState +import com.px4.hawkeye.core.designsystem.HawkeyeDronePalette +import com.px4.hawkeye.core.designsystem.HawkeyeTheme +import com.px4.hawkeye.core.designsystem.wheel.WheelMenuTestTags +import com.px4.hawkeye.core.presentation.UiText +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives [SwarmWheelScreen] with scripted native gesture snapshots — the same sequence the + * polling ViewModel produces — and verifies the projection into the wheel widget: open, + * hover, one-shot selection on release, and close-without-selection paths. + */ +@RunWith(AndroidJUnit4::class) +class SwarmWheelScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private val robot by lazy { SwarmWheelRobot(composeRule) } + + @Test + fun wheelAppearsWhileTheNativeGestureIsOpenAndClosesOnIdle() { + robot + .setContent() + .assertWheelGone() + .open(CENTER) + .assertWheelVisible() + .idle() + .assertWheelGone() + .assertSelections() + } + + @Test + fun releaseOverASliceSelectsThatDroneExactlyOnce() { + robot + .setContent() + .open(CENTER) + .moveTo(RIGHT_SLICE) + .release(RIGHT_SLICE) + .assertWheelGone() + // A later unrelated snapshot with the same seq must not replay the selection. + .idle() + .assertSelections(0) + } + + @Test + fun tornSnapshotWithStaleOpenPhaseDoesNotReopenAfterRelease() { + // The native publish is not a single atomic block: a poll can pair a stale OPEN + // phase with a fresh release seq. The release must win and the wheel stay closed. + robot + .setContent() + .open(CENTER) + .moveTo(RIGHT_SLICE) + .releaseKeepingOpenPhase(RIGHT_SLICE) + .assertWheelGone() + .assertSelections(0) + } + + @Test + fun rejectedGestureClosesWithoutSelection() { + robot + .setContent() + .open(CENTER) + .moveTo(RIGHT_SLICE) + .reject() + .assertWheelGone() + .assertSelections() + } + + @Test + fun releaseInTheHubDeadZoneSelectsNothing() { + robot + .setContent() + .open(CENTER) + .moveTo(RIGHT_SLICE) + .release(CENTER) + .assertWheelGone() + .assertSelections() + } + + private companion object { + // Well inside any screen; slice offset far beyond the 64 dp hub dead zone. + val CENTER = Offset(540f, 800f) + val RIGHT_SLICE = Offset(540f + 400f, 800f) // 3 o'clock = slice 0 of two + } +} + +private class SwarmWheelRobot(private val rule: ComposeContentTestRule) { + + private lateinit var state: MutableState + private val actions = mutableListOf() + + fun setContent() = apply { + state = mutableStateOf( + SwarmWheelState( + items = listOf( + DroneWheelItemUi( + label = UiText.DynamicString("Drone 1"), + hubLabel = UiText.DynamicString("alpha.ulg"), + accentColor = HawkeyeDronePalette.colors[0], + ), + DroneWheelItemUi( + label = UiText.DynamicString("Drone 2"), + hubLabel = UiText.DynamicString("bravo.ulg"), + accentColor = HawkeyeDronePalette.colors[1], + ), + ), + gesture = SwarmWheelSnapshot.Idle.copy(droneCount = 2), + ), + ) + rule.setContent { + HawkeyeTheme { + SwarmWheelScreen(state = state.value, onAction = { actions += it }) + } + } + } + + private fun gesture(transform: (SwarmWheelSnapshot) -> SwarmWheelSnapshot) = apply { + state.value = state.value.copy(gesture = transform(state.value.gesture)) + rule.waitForIdle() + } + + fun open(at: Offset) = gesture { + it.copy( + phase = SwarmWheelSnapshot.PHASE_OPEN, + centerX = at.x, centerY = at.y, + fingerX = at.x, fingerY = at.y, + ) + } + + fun moveTo(finger: Offset) = gesture { it.copy(fingerX = finger.x, fingerY = finger.y) } + + fun release(at: Offset) = gesture { + it.copy( + phase = SwarmWheelSnapshot.PHASE_IDLE, + releaseSeq = it.releaseSeq + 1, + releaseX = at.x, releaseY = at.y, + ) + } + + /** A torn read: the seq bumped but the phase field still carries the stale OPEN. */ + fun releaseKeepingOpenPhase(at: Offset) = gesture { + it.copy( + phase = SwarmWheelSnapshot.PHASE_OPEN, + releaseSeq = it.releaseSeq + 1, + releaseX = at.x, releaseY = at.y, + ) + } + + fun reject() = gesture { it.copy(phase = SwarmWheelSnapshot.PHASE_REJECTED) } + + fun idle() = gesture { it.copy(phase = SwarmWheelSnapshot.PHASE_IDLE) } + + fun assertWheelVisible() = apply { + rule.onNodeWithTag(WheelMenuTestTags.WHEEL).assertIsDisplayed() + } + + fun assertWheelGone() = apply { + rule.onNodeWithTag(WheelMenuTestTags.WHEEL).assertDoesNotExist() + } + + fun assertSelections(vararg drones: Int) = apply { + assertEquals(drones.map { SwarmWheelAction.OnDroneSelected(it) }, actions) + } +} diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 7480230..98b1dff 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -26,6 +26,7 @@ set(MAVLINK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../lib/c_library_v2") add_library(hawkeye SHARED android_main.c replay_jni.c + swarm_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 c7db9d4..2d276f6 100644 --- a/android/app/src/main/cpp/android_main.c +++ b/android/app/src/main/cpp/android_main.c @@ -7,6 +7,9 @@ #include "hud.h" #include "live_marker.h" #include "replay_control.h" +#include "replay_conflict.h" +#include "swarm_control.h" +#include "wheel_gesture.h" #include #include #include @@ -25,6 +28,11 @@ #define HAWKEYE_MAVLINK_PORT 19410 #define MAX_PATH_LEN ASSET_MAX_PATH +// Per-platform cap, mirroring MAX_VEHICLES in main.c / wasm_main.c (each platform main +// owns its own arrays; the shared C never sees the constant). The Kotlin library screen +// enforces the same cap (ReplayLibraryViewModel.MAX_SWARM). +#define MAX_SWARM_VEHICLES 16 + #define TOUCH_ORBIT_SENSITIVITY 0.005f #define TOUCH_ZOOM_SENSITIVITY 0.01f #define TOUCH_PAN_SENSITIVITY 0.002f @@ -362,26 +370,40 @@ static void handle_touch(Camera3D *cam, Vector3 *orbit_target, } } -// 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 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; +// The library repository (Kotlin) stages one or more .ulg files via .tmp + atomic +// rename — inbox/current.ulg for the first (or only) log, inbox/swarm_.ulg for the +// rest of a swarm session — then writes a fresh token into inbox/.ready. We poll the +// sentinel's *contents* (not stat-mtime — f2fs has 1-second granularity and would +// coalesce two stages in the same wall second) once per second and reload when the +// token changes. +static data_source_t g_sources[MAX_SWARM_VEHICLES]; +static int g_source_count = 0; // 0 = no session loaded +static int g_selected = 0; // drone the camera and HUD follow static bool g_live_mode = false; +static bool g_ghost_mode = false; // multi-replay auto-resolved to ghost layout +static bool g_has_tier3 = false; // any drone without a valid parsed home static long long g_last_ready_token = 0; static hud_t g_hud; // Initialized to a large negative value so the first poll always proceeds. static double g_last_poll_time = -1e9; // For camera-follow: each frame we translate orbit_target and camera.position -// by (vehicle.position - g_last_vehicle_pos). Initialized to vehicle.position -// at startup, and re-seeded on every reload so the first delta is zero. +// by (selected vehicle position - g_last_vehicle_pos). Initialized at startup, +// and re-seeded on every reload so the first delta is zero. A drone switch via +// the wheel leaves it pointing at the old drone's position on purpose: the next +// frame's delta then carries the camera to the new drone with the user's orbit +// offset intact. static Vector3 g_last_vehicle_pos = {0}; +// Tap-and-hold detector for the swarm drone-selection wheel (shared header, +// platform-fed). Armed only for multi-drone replay sessions. +static wheel_gesture_t g_wheel = {0}; + // Returns the millis token in the sentinel file, or 0 on missing/empty/parse-fail. -static long long read_ready_token(const char *path) { +// The token is "" for a single log or " " for a swarm batch; +// *out_count gets the validated count (default 1). +static long long read_ready_token(const char *path, int *out_count) { + *out_count = 1; FILE *f = fopen(path, "rb"); if (!f) return 0; char buf[64]; @@ -391,7 +413,10 @@ static long long read_ready_token(const char *path) { buf[n] = '\0'; char *end = NULL; long long val = strtoll(buf, &end, 10); - return (end == buf) ? 0 : val; + if (end == buf) return 0; + long count = strtol(end, NULL, 10); + if (count >= 1 && count <= MAX_SWARM_VEHICLES) *out_count = (int)count; + return val; } // Reads the live marker file and parses " [port]" via parse_live_marker @@ -409,7 +434,47 @@ static long long read_live_marker(const char *path, uint16_t *out_port) { return parse_live_marker(buf, HAWKEYE_MAVLINK_PORT, out_port); } -static int try_load_inbox_ulog(vehicle_t *vehicle) { +// Applies the auto-resolved multi-drone layout. A trimmed adaptation of wasm_main.c's +// apply_replay_mode (P-key dialog variant), using home-based origins like the existing +// single-log path: vehicle_update's first-sample origin latch can catch a transient +// lat=0 state, while the ULog pre-scan's home is reliable. +// 1 = formation (shared NED origin, real relative positions) +// 2 = ghost (own home origin, non-primary drones at 0.35 alpha) +// 3 = grid (own home origin, +5 m X offset per drone) +static void apply_swarm_layout(vehicle_t *vehicles, int count, + double ref_lat_rad, double ref_lon_rad, + double min_alt, int mode) { + for (int i = 0; i < count; i++) { + vehicles[i].grid_offset = (Vector3){0, 0, 0}; + vehicle_set_ghost_alpha(&vehicles[i], 1.0f); + + if (mode == 1) { + if (g_sources[i].home.valid) { + vehicles[i].lat0 = ref_lat_rad; + vehicles[i].lon0 = ref_lon_rad; + vehicles[i].alt0 = min_alt; + vehicles[i].origin_set = true; + } + } else if (g_sources[i].home.valid) { + vehicles[i].lat0 = g_sources[i].home.lat * 1e-7 * (M_PI / 180.0); + vehicles[i].lon0 = g_sources[i].home.lon * 1e-7 * (M_PI / 180.0); + vehicles[i].alt0 = g_sources[i].home.alt * 1e-3; + vehicles[i].origin_set = true; + } + // No valid home: leave origin unset for vehicle_update's wait-and-latch. + } + + if (mode == 2) { + for (int i = 1; i < count; i++) + vehicle_set_ghost_alpha(&vehicles[i], 0.35f); + } else if (mode == 3) { + for (int i = 1; i < count; i++) + vehicles[i].grid_offset = (Vector3){ i * 5.0f, 0.0f, 0.0f }; + } +} + +static int try_load_inbox_swarm(const scene_t *scene, vehicle_t *vehicles, + int *vehicle_inited_count) { // 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 @@ -420,62 +485,215 @@ static int try_load_inbox_ulog(vehicle_t *vehicle) { char ready[MAX_PATH_LEN]; snprintf(ready, sizeof(ready), "%s/inbox/.ready", s_internal_data_path); - long long token = read_ready_token(ready); + int count = 1; + long long token = read_ready_token(ready, &count); if (token == 0 || token == g_last_ready_token) return 0; - // Parse-then-swap: try to construct the new data source first. If it - // fails (corrupt file, partial copy, etc.) the previous replay keeps - // playing — we don't tear down working state on speculation. - char ulg[MAX_PATH_LEN]; - snprintf(ulg, sizeof(ulg), "%s/inbox/current.ulg", s_internal_data_path); - data_source_t new_ds = {0}; - int rc = data_source_ulog_create(&new_ds, ulg); - if (rc != 0) { - __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-open the log to trigger another attempt. - g_last_ready_token = token; - return 0; + // Parse-then-swap: construct every new data source first. If any fails + // (corrupt file, partial copy, etc.) the previous session keeps playing — + // we don't tear down working state on speculation. + data_source_t new_sources[MAX_SWARM_VEHICLES]; + memset(new_sources, 0, sizeof(new_sources)); + for (int i = 0; i < count; i++) { + char ulg[MAX_PATH_LEN]; + if (i == 0) + snprintf(ulg, sizeof(ulg), "%s/inbox/current.ulg", s_internal_data_path); + else + snprintf(ulg, sizeof(ulg), "%s/inbox/swarm_%d.ulg", s_internal_data_path, i); + int rc = data_source_ulog_create(&new_sources[i], ulg); + if (rc != 0) { + __android_log_print(ANDROID_LOG_ERROR, "Hawkeye", + "data_source_ulog_create(%s) failed: %d", ulg, rc); + for (int j = 0; j < i; j++) data_source_close(&new_sources[j]); + // Mark the bad token consumed so we don't retry every second; the + // user must re-stage to trigger another attempt. + g_last_ready_token = token; + return 0; + } } - if (g_ds_active) { - data_source_close(&g_ds); - vehicle_reset_trail(vehicle); - } - g_ds = new_ds; - g_ds_active = true; + // Commit: swap the session over. The wheel machine resets too, so a gesture phase + // from the old session can never linger into one where it is no longer ticked. + for (int i = 0; i < g_source_count; i++) data_source_close(&g_sources[i]); + for (int i = 0; i < count; i++) g_sources[i] = new_sources[i]; + g_source_count = count; + g_selected = 0; + g_wheel = (wheel_gesture_t){0}; g_last_ready_token = token; - // Reset origin tracking so a new replay without a valid home falls back to - // vehicle_update's wait-and-latch logic instead of inheriting the previous - // replay's origin (lat0/lon0/alt0 would otherwise stay set from the old log). - vehicle->origin_set = false; - vehicle->origin_wait_count = 0; - - // Pre-seed origin from the parsed home so positions are computed relative - // to home. vehicle_update's own first-sample origin-init latches onto - // whatever state it sees first — if state.lat is briefly 0 (drone powered - // on, no GPS lock yet), it sets lat0=0 and the rest of the flight renders - // at absolute lat/lon coordinates millions of meters from origin. The ULog - // pre-scan populates ctx->home reliably, so use it when available. - if (g_ds.home.valid) { - vehicle->lat0 = g_ds.home.lat * 1e-7 * (M_PI / 180.0); - vehicle->lon0 = g_ds.home.lon * 1e-7 * (M_PI / 180.0); - vehicle->alt0 = g_ds.home.alt * 1e-3; - vehicle->origin_set = true; + // Vehicles: init slots a larger session needs, release slots a smaller one + // frees. Loading models here causes a one-time hitch at session start, same + // as desktop startup. + for (int i = *vehicle_inited_count; i < count; i++) + vehicle_init(&vehicles[i], MODEL_QUADROTOR, scene->lighting_shader); + for (int i = count; i < *vehicle_inited_count; i++) + vehicle_cleanup(&vehicles[i]); + *vehicle_inited_count = count; + + for (int i = 0; i < count; i++) { + vehicle_reset_trail(&vehicles[i]); + // Multi sessions color drones from the theme palette (matches trails and + // the Kotlin wheel); single sessions keep the default white tint. + vehicles[i].color = (count > 1) ? scene->theme->drone_palette[i % 16] : WHITE; + if (g_sources[i].mav_type != 0) + vehicle_set_type(&vehicles[i], g_sources[i].mav_type); + // Reset origin tracking so a replay without a valid home falls back to + // vehicle_update's wait-and-latch logic instead of inheriting the + // previous session's origin. + vehicles[i].origin_set = false; + vehicles[i].origin_wait_count = 0; + } + + if (count == 1) { + g_ghost_mode = false; + g_has_tier3 = false; + // Pre-seed origin from the parsed home so positions are computed relative + // to home. vehicle_update's own first-sample origin-init latches onto + // whatever state it sees first — if state.lat is briefly 0 (drone powered + // on, no GPS lock yet), it sets lat0=0 and the rest of the flight renders + // at absolute lat/lon coordinates millions of meters from origin. The ULog + // pre-scan populates ctx->home reliably, so use it when available. + if (g_sources[0].home.valid) { + vehicles[0].lat0 = g_sources[0].home.lat * 1e-7 * (M_PI / 180.0); + vehicles[0].lon0 = g_sources[0].home.lon * 1e-7 * (M_PI / 180.0); + vehicles[0].alt0 = g_sources[0].home.alt * 1e-3; + vehicles[0].origin_set = true; + } + } else { + // Shared NED reference: first valid home for lat/lon, lowest home for alt + // (mirrors wasm_main.c's finalize). + double ref_lat_rad = 0.0, ref_lon_rad = 0.0, min_alt = 1e9; + for (int i = 0; i < count; i++) { + if (g_sources[i].home.valid) { + ref_lat_rad = g_sources[i].home.lat * 1e-7 * (M_PI / 180.0); + ref_lon_rad = g_sources[i].home.lon * 1e-7 * (M_PI / 180.0); + break; + } + } + for (int i = 0; i < count; i++) { + if (g_sources[i].home.valid) { + double a = g_sources[i].home.alt * 1e-3; + if (a < min_alt) min_alt = a; + } + } + if (min_alt > 1e8) min_alt = 0.0; + + g_has_tier3 = false; + for (int i = 0; i < count; i++) { + if (!g_sources[i].home.valid) { g_has_tier3 = true; break; } + } + + // Auto-resolve the layout (no dialog on Android): overlapping homes + // render as ghosts, far-apart homes side by side on a grid, otherwise + // formation with real relative positions. + conflict_result_t cr = replay_detect_conflict(g_sources, count); + int mode = !cr.conflict_detected ? 1 : (cr.conflict_far ? 3 : 2); + g_ghost_mode = (mode == 2); + apply_swarm_layout(vehicles, count, ref_lat_rad, ref_lon_rad, min_alt, mode); + __android_log_print(ANDROID_LOG_INFO, "Hawkeye", + "swarm layout: mode=%d conflict=%d far=%d tier3=%d", + mode, cr.conflict_detected, cr.conflict_far, g_has_tier3); } // Re-seed the follow baseline so the first frame's delta covers the jump - // from the previous replay's last position (or the dummy startup position) - // to this replay's first valid sample — keeps the new drone in view. - g_last_vehicle_pos = vehicle->position; + // from the previous session's last position (or the dummy startup position) + // to this session's first valid sample — keeps the selected drone in view. + g_last_vehicle_pos = vehicles[0].position; __android_log_print(ANDROID_LOG_INFO, "Hawkeye", - "loaded ulg: duration=%.1fs token=%lld", g_ds.playback.duration_s, token); + "loaded %d ulg file(s): duration=%.1fs token=%lld", + count, g_sources[0].playback.duration_s, token); return 1; } +// --------------------------------------------------------------------------- +// Edge indicator chevrons for off-screen drones. Ported verbatim from +// wasm_main.c:120-201 (itself a port of main.c:62-145); each platform main +// carries its own copy because the helper draws with that platform's screen +// metrics and the shared C layer stays untouched. +// --------------------------------------------------------------------------- + +static void draw_edge_indicators(const vehicle_t *vehicles, int vehicle_count, + int selected, Camera3D camera, Font font) +{ + int ei_sw = GetScreenWidth(); + int ei_sh = GetScreenHeight(); + float ei_margin = 40.0f; + float ei_scale = powf(ei_sh / 720.0f, 0.7f); + if (ei_scale < 1.0f) ei_scale = 1.0f; + Vector3 cam_fwd = Vector3Normalize(Vector3Subtract( + camera.target, camera.position)); + + for (int i = 0; i < vehicle_count; i++) { + if (i == selected || !vehicles[i].active) continue; + + Vector3 to_drone = Vector3Subtract(vehicles[i].position, + camera.position); + float dot = to_drone.x * cam_fwd.x + to_drone.y * cam_fwd.y + + to_drone.z * cam_fwd.z; + + Vector2 sp = GetWorldToScreen(vehicles[i].position, camera); + + if (sp.x >= ei_margin && sp.x <= ei_sw - ei_margin && + sp.y >= ei_margin && sp.y <= ei_sh - ei_margin) continue; + + float ei_cx = ei_sw / 2.0f; + float ei_cy = ei_sh / 2.0f; + float ei_dx = sp.x - ei_cx; + float ei_dy = sp.y - ei_cy; + + if (dot < 0.5f) { + Vector3 cam_right = Vector3Normalize( + Vector3CrossProduct(cam_fwd, (Vector3){0, 1, 0})); + Vector3 cam_up_approx = Vector3CrossProduct(cam_right, cam_fwd); + ei_dx = Vector3DotProduct(to_drone, cam_right); + ei_dy = -Vector3DotProduct(to_drone, cam_up_approx); + float len = sqrtf(ei_dx * ei_dx + ei_dy * ei_dy); + if (len > 0.01f) { ei_dx /= len; ei_dy /= len; } + ei_dx *= ei_sw; + ei_dy *= ei_sh; + } + + float sx = (ei_dx != 0) + ? ((ei_dx > 0 ? ei_sw - ei_margin : ei_margin) - ei_cx) / ei_dx + : 1e9f; + float sy = (ei_dy != 0) + ? ((ei_dy > 0 ? ei_sh - ei_margin : ei_margin) - ei_cy) / ei_dy + : 1e9f; + float se = fminf(fabsf(sx), fabsf(sy)); + float ex = ei_cx + ei_dx * se; + float ey = ei_cy + ei_dy * se; + if (ex < ei_margin) ex = ei_margin; + if (ex > ei_sw - ei_margin) ex = ei_sw - ei_margin; + if (ey < ei_margin) ey = ei_margin; + if (ey > ei_sh - ei_margin) ey = ei_sh - ei_margin; + + Color col = vehicles[i].color; + col.a = 220; + float angle = atan2f(ei_dy, ei_dx); + float sz = 14.0f * ei_scale; + + float chev_len = sz * 1.2f; + float chev_spread = 0.5f; + Vector2 tip = { ex + cosf(angle) * chev_len, + ey + sinf(angle) * chev_len }; + Vector2 cl = { ex + cosf(angle + chev_spread) * sz * 0.6f, + ey + sinf(angle + chev_spread) * sz * 0.6f }; + Vector2 cr = { ex + cosf(angle - chev_spread) * sz * 0.6f, + ey + sinf(angle - chev_spread) * sz * 0.6f }; + DrawLineEx(tip, cl, 2.5f * ei_scale, col); + DrawLineEx(tip, cr, 2.5f * ei_scale, col); + + char num[4]; + snprintf(num, sizeof(num), "%d", i + 1); + float lfs = 18.0f * ei_scale; + Vector2 tw = MeasureTextEx(font, num, lfs, 0.5f); + float lx = ex - cosf(angle) * (sz * 0.3f) - tw.x / 2; + float ly = ey - sinf(angle) * (sz * 0.3f) - tw.y / 2; + DrawTextEx(font, num, (Vector2){ lx, ly }, lfs, 0.5f, col); + } +} + // Raylib's rcore_android.c owns android_main and calls user main() after platform setup. int main(int argc, char *argv[]) { (void)argc; (void)argv; @@ -508,8 +726,11 @@ int main(int argc, char *argv[]) { scene_t scene = {0}; scene_init(&scene); - vehicle_t vehicle = {0}; - vehicle_init(&vehicle, MODEL_QUADROTOR, scene.lighting_shader); + // Slot 0 is always initialized; a swarm session lazily initializes the rest + // (and releases them again when a smaller session loads). + vehicle_t vehicles[MAX_SWARM_VEHICLES] = {0}; + int vehicle_inited_count = 1; + vehicle_init(&vehicles[0], MODEL_QUADROTOR, scene.lighting_shader); hud_init(&g_hud); // The transport bar is driven by a Compose overlay on Android, so suppress the @@ -520,19 +741,19 @@ int main(int argc, char *argv[]) { g_hud.scale_mul = 1.5f; // Position vehicle slightly above origin so it's visible with the default camera - vehicle.position = (Vector3){ 0.0f, 0.5f, 0.0f }; + vehicles[0].position = (Vector3){ 0.0f, 0.5f, 0.0f }; scene.cam_mode = CAM_MODE_FREE; scene.free_track = true; - Vector3 orbit_target = vehicle.position; + Vector3 orbit_target = vehicles[0].position; Vector2 prev_touch = {0}; float prev_pinch_dist = 0.0f; Vector2 prev_mid = {0}; int prev_count = 0; scene.camera.target = orbit_target; - g_last_vehicle_pos = vehicle.position; + g_last_vehicle_pos = vehicles[0].position; // Decide live vs replay from the inbox markers (newest token wins). The Kotlin // launcher writes inbox/.live for a Connect tap and inbox/.ready for a replay; a @@ -544,7 +765,8 @@ int main(int argc, char *argv[]) { snprintf(live_path, sizeof(live_path), "%s/inbox/.live", s_internal_data_path); snprintf(ready_path, sizeof(ready_path), "%s/inbox/.ready", s_internal_data_path); long long live_token = read_live_marker(live_path, &live_port); - long long ready_token = read_ready_token(ready_path); + int ready_count = 1; + long long ready_token = read_ready_token(ready_path, &ready_count); // >= so an (unreachable) tie favors the explicit live intent; in practice the two // tokens come from distinct user actions stamped at different millis. if (live_token > 0 && live_token >= ready_token) { @@ -554,8 +776,8 @@ int main(int argc, char *argv[]) { // sessions (HawkeyeActivity.isLiveMode), so a replay fallback would render with no // transport controls at all. On bind failure we stay in the live "Waiting…" state. g_live_mode = true; - if (data_source_mavlink_create(&g_ds, live_port, /*channel=*/0, false) == 0) { - g_ds_active = true; + if (data_source_mavlink_create(&g_sources[0], live_port, /*channel=*/0, false) == 0) { + g_source_count = 1; __android_log_print(ANDROID_LOG_INFO, "Hawkeye", "live MAVLink: listening on UDP %u", live_port); } else { @@ -566,91 +788,138 @@ int main(int argc, char *argv[]) { } } - // Replay: pick up a .ulg already staged into the inbox before native main() ran + // Replay: pick up the session already staged into the inbox before native main() ran // (skipped in live mode so the inbox never overrides a live session). - if (!g_live_mode) try_load_inbox_ulog(&vehicle); + if (!g_live_mode) try_load_inbox_swarm(&scene, vehicles, &vehicle_inited_count); while (!WindowShouldClose()) { - // Catch a new log staged while the renderer is already running (replay only). - if (!g_live_mode) try_load_inbox_ulog(&vehicle); + // Catch a new session staged while the renderer is already running (replay only). + if (!g_live_mode) try_load_inbox_swarm(&scene, vehicles, &vehicle_inited_count); + + bool active = g_source_count > 0; + + // Consume a drone selection posted by the wheel overlay (JVM thread). The + // camera-follow delta below then carries the camera to the new drone. + int sel_req = swarm_control_take_select(); + if (sel_req >= 0 && sel_req < g_source_count) g_selected = sel_req; - // Apply pending transport requests from the Compose overlay (JVM thread). - replay_control_apply(&g_ds, g_ds_active); + // Apply pending transport requests from the Compose overlay (JVM thread); + // a seek moves every drone, so all trails restart together. + if (replay_control_apply(g_sources, g_source_count, active)) { + for (int i = 0; i < g_source_count; i++) vehicle_reset_trail(&vehicles[i]); + } - if (g_ds_active) { - data_source_poll(&g_ds, GetFrameTime()); + if (active) { + float dt = GetFrameTime(); + for (int i = 0; i < g_source_count; i++) { + data_source_poll(&g_sources[i], dt); + vehicle_update(&vehicles[i], &g_sources[i].state, &g_sources[i].home); + } if (g_live_mode) { static bool s_was_connected = false; - if (g_ds.connected != s_was_connected) { - s_was_connected = g_ds.connected; + if (g_sources[0].connected != s_was_connected) { + s_was_connected = g_sources[0].connected; __android_log_print(ANDROID_LOG_INFO, "Hawkeye", - "live MAVLink: connected=%d sysid=%u", g_ds.connected, g_ds.sysid); + "live MAVLink: connected=%d sysid=%u", + g_sources[0].connected, g_sources[0].sysid); } } - vehicle_update(&vehicle, &g_ds.state, &g_ds.home); // Camera follow: translate the orbit center and the camera by the - // vehicle's frame-to-frame motion so the user's orbit/pan offset - // (relative to the vehicle) stays constant. - if (g_ds.state.valid) { - Vector3 delta = Vector3Subtract(vehicle.position, g_last_vehicle_pos); + // selected vehicle's frame-to-frame motion so the user's orbit/pan + // offset (relative to the vehicle) stays constant. + if (g_sources[g_selected].state.valid) { + Vector3 delta = Vector3Subtract(vehicles[g_selected].position, + g_last_vehicle_pos); orbit_target = Vector3Add(orbit_target, delta); scene.camera.position = Vector3Add(scene.camera.position, delta); scene.camera.target = orbit_target; - g_last_vehicle_pos = vehicle.position; + g_last_vehicle_pos = vehicles[g_selected].position; } } - // Publish playback + live status for the Compose overlay to read via JNI. - replay_control_publish(&g_ds, g_ds_active); - live_status_publish(&g_ds, g_ds_active, g_live_mode, live_port); - - handle_touch(&scene.camera, &orbit_target, - &prev_count, &prev_touch, - &prev_pinch_dist, &prev_mid); + // Publish playback + live status for the Compose overlays to read via JNI. + replay_control_publish(&g_sources[g_selected], active); + live_status_publish(&g_sources[0], active, g_live_mode, live_port); + + // Tap-and-hold wheel gesture, armed only for multi-drone replay. Runs before + // the camera handler: while the wheel owns the gesture (PENDING/OPEN) camera + // input is suppressed; resetting prev_count makes handle_touch re-seed when + // it takes over again, so neither hand-off jumps the camera. + bool wheel_owns = false; + { + int touch_count = GetTouchPointCount(); + Vector2 touch = (touch_count > 0) + ? GetTouchPosition(0) + : (Vector2){ g_wheel.finger_x, g_wheel.finger_y }; + if (g_source_count >= 2 && !g_live_mode) { + wheel_owns = wheel_gesture_update(&g_wheel, touch_count, + touch.x, touch.y, GetFrameTime()); + } + swarm_control_publish(&g_wheel, g_source_count, g_selected); + } + if (wheel_owns) { + prev_count = -1; + } else { + handle_touch(&scene.camera, &orbit_target, + &prev_count, &prev_touch, + &prev_pinch_dist, &prev_mid); + } hud_update(&g_hud, - g_ds_active ? g_ds.state.time_usec : 0, - g_ds_active ? g_ds.connected : false, + active ? g_sources[g_selected].state.time_usec : 0, + active ? g_sources[g_selected].connected : false, GetFrameTime()); BeginDrawing(); scene_draw_sky(&scene); + int trail_mode = active ? ((g_source_count > 1) ? 3 : 1) : 0; BeginMode3D(scene.camera); scene_draw(&scene); - vehicle_draw(&vehicle, scene.theme, - /*selected=*/true, - /*trail_mode=*/g_ds_active ? 1 : 0, - /*show_ground_track=*/false, - /*cam_pos=*/scene.camera.position, - /*classic_colors=*/false); + int draw_count = active ? g_source_count : 1; + for (int i = 0; i < draw_count; i++) { + vehicle_draw(&vehicles[i], scene.theme, + /*selected=*/i == g_selected, + trail_mode, + /*show_ground_track=*/false, + /*cam_pos=*/scene.camera.position, + /*classic_colors=*/false); + } EndMode3D(); + if (g_source_count > 1) { + draw_edge_indicators(vehicles, g_source_count, g_selected, + scene.camera, g_hud.font_value); + } + // HUD overlay. When no .ulg is loaded, hand hud_draw a zeroed // data_source so the status row reads "Waiting…" without crashing. { data_source_t empty_src = {0}; - const data_source_t *src_ptr = g_ds_active ? &g_ds : &empty_src; - hud_marker_data_t user_md = {0}; - hud_marker_data_t sys_md = {0}; - bool has_awaiting_gps = g_ds_active && !vehicle.origin_set && g_ds.home.valid; + const data_source_t *src_ptr = active ? g_sources : &empty_src; + int hud_count = active ? g_source_count : 1; + int hud_selected = active ? g_selected : 0; + // Marker overlays are not surfaced on Android: zeroed per-drone + // entries keep hud_draw's indexing in bounds. + hud_marker_data_t user_md[MAX_SWARM_VEHICLES] = {0}; + hud_marker_data_t sys_md[MAX_SWARM_VEHICLES] = {0}; + bool has_awaiting_gps = active && !vehicles[hud_selected].origin_set + && g_sources[hud_selected].home.valid; if (g_hud.mode == HUD_CONSOLE) { - hud_draw(&g_hud, &vehicle, src_ptr, /*vehicle_count=*/1, /*selected=*/0, + hud_draw(&g_hud, vehicles, src_ptr, hud_count, hud_selected, GetScreenWidth(), GetScreenHeight(), - scene.theme, /*trail_mode=*/g_ds_active ? 1 : 0, - &user_md, &sys_md, /*marker_vehicle_count=*/1, - /*ghost_mode=*/false, /*has_tier3=*/false, has_awaiting_gps); + scene.theme, trail_mode, + user_md, sys_md, /*marker_vehicle_count=*/hud_count, + g_ghost_mode, g_has_tier3, has_awaiting_gps); } } EndDrawing(); } - if (g_ds_active) { - data_source_close(&g_ds); - g_ds_active = false; - } + for (int i = 0; i < g_source_count; i++) data_source_close(&g_sources[i]); + g_source_count = 0; hud_cleanup(&g_hud); - vehicle_cleanup(&vehicle); + for (int i = 0; i < vehicle_inited_count; i++) vehicle_cleanup(&vehicles[i]); scene_cleanup(&scene); SetLoadFileTextCallback(NULL); SetLoadFileDataCallback(NULL); diff --git a/android/app/src/main/cpp/replay_control.h b/android/app/src/main/cpp/replay_control.h index dc9ac46..c7fbf8c 100644 --- a/android/app/src/main/cpp/replay_control.h +++ b/android/app/src/main/cpp/replay_control.h @@ -37,7 +37,11 @@ typedef struct { 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); +// +// apply fans pause/speed/seek out to all `count` sources (a swarm session seeks and +// pauses as one). Returns true when a seek request was consumed, so the caller can +// reset every vehicle trail. publish snapshots the source the HUD/transport follow. +bool replay_control_apply(struct data_source *sources, int count, bool active); void replay_control_publish(struct data_source *ds, bool active); // Publishes the live MAVLink connection snapshot (no-op outside live mode). diff --git a/android/app/src/main/cpp/replay_jni.c b/android/app/src/main/cpp/replay_jni.c index e119d3b..e6df514 100644 --- a/android/app/src/main/cpp/replay_jni.c +++ b/android/app/src/main/cpp/replay_jni.c @@ -5,19 +5,23 @@ // 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; +bool replay_control_apply(struct data_source *sources, int count, bool active) { + if (!active || count <= 0) return false; 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)); + bool speed_set = atomic_exchange(&g_replay_control.req_speed_set, false); + float speed = atomic_load(&g_replay_control.req_speed); + bool seek_set = atomic_exchange(&g_replay_control.req_seek_set, false); + float seek_s = atomic_load(&g_replay_control.req_seek_s); + + for (int i = 0; i < count; i++) { + struct data_source *ds = &sources[i]; + if (pause == 0) ds->playback.paused = false; + else if (pause == 1) ds->playback.paused = true; + if (speed_set) ds->playback.speed = speed; + if (seek_set) data_source_seek(ds, seek_s); } + return seek_set; } void replay_control_publish(struct data_source *ds, bool active) { diff --git a/android/app/src/main/cpp/swarm_control.h b/android/app/src/main/cpp/swarm_control.h new file mode 100644 index 0000000..621acb9 --- /dev/null +++ b/android/app/src/main/cpp/swarm_control.h @@ -0,0 +1,46 @@ +#ifndef SWARM_CONTROL_H +#define SWARM_CONTROL_H + +#include +#include "wheel_gesture.h" + +/* + * Lock-free bridge between the JVM main thread (the Compose swarm-wheel overlay) and + * raylib's render thread, following the replay_control.h pattern: the render thread is + * the only writer of the snapshot fields and the only consumer of the request fields. + * + * The render loop owns the tap-and-hold detection (wheel_gesture.h fed from raylib's + * touch state) and publishes the machine's phase plus the session shape every frame; + * the overlay polls the snapshot, draws the wheel, resolves the hovered slice in + * Kotlin, and posts the chosen drone index back through req_select. + */ +typedef struct { + // Request: written by the JVM, consumed (reset to -1) by the render thread. + atomic_int req_select; // -1 = none, else drone index to make current + + // Wheel gesture snapshot: written by the render thread, read by the JVM. + atomic_int snap_phase; // wheel_phase_t as int + _Atomic float snap_center_x; // where the wheel opened (surface px) + _Atomic float snap_center_y; + _Atomic float snap_finger_x; // latest finger position while open + _Atomic float snap_finger_y; + atomic_uint snap_release_seq; // bumped once per release-while-open + _Atomic float snap_release_x; // finger position at that release + _Atomic float snap_release_y; + + // Session snapshot: written by the render thread, read by the JVM. + atomic_int snap_drone_count; // 0 until a session loads + atomic_int snap_selected; // index the camera/HUD currently follow +} swarm_control_t; + +extern swarm_control_t g_swarm_control; + +// Render-thread side, called from the android_main loop each frame. + +// Returns the pending drone-selection request (and clears it), or -1 if none. +int swarm_control_take_select(void); + +// Publishes the wheel gesture state and session shape for the overlay to poll. +void swarm_control_publish(const wheel_gesture_t *g, int drone_count, int selected); + +#endif diff --git a/android/app/src/main/cpp/swarm_jni.c b/android/app/src/main/cpp/swarm_jni.c new file mode 100644 index 0000000..78a7145 --- /dev/null +++ b/android/app/src/main/cpp/swarm_jni.c @@ -0,0 +1,58 @@ +#include "swarm_control.h" +#include + +// req_select starts at -1 (no request); every other field is fine zero-initialized. +swarm_control_t g_swarm_control = { .req_select = -1 }; + +int swarm_control_take_select(void) { + return atomic_exchange(&g_swarm_control.req_select, -1); +} + +void swarm_control_publish(const wheel_gesture_t *g, int drone_count, int selected) { + atomic_store(&g_swarm_control.snap_phase, (int)g->phase); + atomic_store(&g_swarm_control.snap_center_x, g->center_x); + atomic_store(&g_swarm_control.snap_center_y, g->center_y); + atomic_store(&g_swarm_control.snap_finger_x, g->finger_x); + atomic_store(&g_swarm_control.snap_finger_y, g->finger_y); + atomic_store(&g_swarm_control.snap_release_x, g->release_x); + atomic_store(&g_swarm_control.snap_release_y, g->release_y); + // release_seq last: a JVM poll that sees the new seq is guaranteed to also see the + // matching release position written above. + atomic_store(&g_swarm_control.snap_release_seq, g->release_seq); + atomic_store(&g_swarm_control.snap_drone_count, drone_count); + atomic_store(&g_swarm_control.snap_selected, selected); +} + +// --- JNI surface for com.px4.hawkeye.android.render.NativeSwarmController --- + +// Returns [phase, centerX, centerY, fingerX, fingerY, releaseSeq, releaseX, releaseY, +// droneCount, selected]. +JNIEXPORT jfloatArray JNICALL +Java_com_px4_hawkeye_android_render_NativeSwarmController_nativeGetWheel( + JNIEnv *env, jobject thiz) { + (void)thiz; + jfloat values[10]; + values[0] = (jfloat)atomic_load(&g_swarm_control.snap_phase); + values[1] = atomic_load(&g_swarm_control.snap_center_x); + values[2] = atomic_load(&g_swarm_control.snap_center_y); + values[3] = atomic_load(&g_swarm_control.snap_finger_x); + values[4] = atomic_load(&g_swarm_control.snap_finger_y); + values[5] = (jfloat)atomic_load(&g_swarm_control.snap_release_seq); + values[6] = atomic_load(&g_swarm_control.snap_release_x); + values[7] = atomic_load(&g_swarm_control.snap_release_y); + values[8] = (jfloat)atomic_load(&g_swarm_control.snap_drone_count); + values[9] = (jfloat)atomic_load(&g_swarm_control.snap_selected); + + jfloatArray array = (*env)->NewFloatArray(env, 10); + if (array != NULL) { + (*env)->SetFloatArrayRegion(env, array, 0, 10, values); + } + return array; +} + +JNIEXPORT void JNICALL +Java_com_px4_hawkeye_android_render_NativeSwarmController_nativeSelectDrone( + JNIEnv *env, jobject thiz, jint index) { + (void)env; (void)thiz; + atomic_store(&g_swarm_control.req_select, (int)index); +} 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 9f3f776..dd600c8 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 @@ -26,10 +26,13 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.px4.hawkeye.android.render.NativeLiveStatusController import com.px4.hawkeye.android.render.NativeReplayController +import com.px4.hawkeye.android.render.NativeSwarmController import com.px4.hawkeye.android.render.RenderMode import com.px4.hawkeye.android.render.RendererLauncher import com.px4.hawkeye.android.render.live.LiveStatusRoot import com.px4.hawkeye.android.render.live.LiveStatusViewModel +import com.px4.hawkeye.android.render.swarm.SwarmWheelRoot +import com.px4.hawkeye.android.render.swarm.SwarmWheelViewModel import com.px4.hawkeye.android.render.transport.TransportRoot import com.px4.hawkeye.android.render.transport.TransportViewModel import com.px4.hawkeye.core.designsystem.HawkeyeTheme @@ -84,7 +87,14 @@ class HawkeyeActivity : LiveStatusViewModel(NativeLiveStatusController(), deviceIp) as T } + private val swarmWheelViewModelFactory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + SwarmWheelViewModel(NativeSwarmController(), droneLabels) as T + } + private var overlay: ComposeView? = null + private var wheelOverlay: ComposeView? = null // Read once from the launch Intent. Safe to cache: the activity uses the default // (standard) launch mode, so each launch is a brand-new instance with a fresh Intent; @@ -100,6 +110,12 @@ class HawkeyeActivity : intent?.getStringExtra(RendererLauncher.EXTRA_DEVICE_IP) } + // Staged logs' display names in drone order, for the swarm wheel's slice labels. The + // ViewModel falls back to numbered names when a label is missing or blank. + private val droneLabels: List by lazy { + intent?.getStringArrayExtra(RendererLauncher.EXTRA_DRONE_LABELS)?.toList().orEmpty() + } + override fun onCreate(savedInstanceState: Bundle?) { savedStateRegistryController.performAttach() savedStateRegistryController.performRestore(savedInstanceState) @@ -133,8 +149,9 @@ class HawkeyeActivity : 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. + // The window is attached and has a valid token by now, so the panels can be added. attachOverlay() + attachWheelOverlay() } } @@ -193,6 +210,59 @@ class HawkeyeActivity : overlay = composeView } + /** + * Adds the swarm drone-selection wheel as a second, full-screen panel above the renderer + * (and above the transport bar — added later, so it z-orders on top; it only draws while + * a hold gesture is in progress). Replay sessions only. + * + * The transport panel and this one split the overlay duties by touch policy, which is + * window-global: the transport bar must RECEIVE touches (its wrap-height strip passes + * the rest through by geometry), while the wheel surface must pass EVERY touch through + * to the GL surface — the native engine owns the tap-and-hold detection — hence + * FLAG_NOT_TOUCHABLE and MATCH_PARENT height here. + */ + private fun attachWheelOverlay() { + if (wheelOverlay != null || isLiveMode) return + val composeView = ComposeView(this).apply { + setBackgroundColor(Color.TRANSPARENT) + setViewTreeLifecycleOwner(this@HawkeyeActivity) + setViewTreeViewModelStoreOwner(this@HawkeyeActivity) + setViewTreeSavedStateRegistryOwner(this@HawkeyeActivity) + setContent { + HawkeyeTheme { + SwarmWheelRoot( + viewModel = ViewModelProvider( + this@HawkeyeActivity, swarmWheelViewModelFactory, + )[SwarmWheelViewModel::class.java], + ) + } + } + } + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, + // NOT_TOUCHABLE routes every MotionEvent to the windows below (the GL surface); + // NOT_FOCUSABLE leaves Back with the renderer; NO_LIMITS + cutout ALWAYS (below) + // give the overlay the same full-display pixel space as the GL surface, so the + // native gesture coordinates and the wheel drawing coordinates line up 1:1. + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE 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) + wheelOverlay = composeView + } + override fun onStart() { super.onStart() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) 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 8845f3e..1584c52 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 @@ -17,12 +17,14 @@ object RendererLauncher { const val EXTRA_MODE = "com.px4.hawkeye.android.render.MODE" const val EXTRA_DEVICE_IP = "com.px4.hawkeye.android.render.DEVICE_IP" + const val EXTRA_DRONE_LABELS = "com.px4.hawkeye.android.render.DRONE_LABELS" fun launch( context: Context, mode: RenderMode = RenderMode.REPLAY, listenPort: Int = DEFAULT_LIVE_PORT, deviceIp: String? = null, + droneLabels: List = emptyList(), ) { if (mode == RenderMode.LIVE) { writeLiveSessionMarker( @@ -32,6 +34,7 @@ object RendererLauncher { context.startActivity( Intent(context, HawkeyeActivity::class.java) .putExtra(EXTRA_MODE, mode.name) + .putExtra(EXTRA_DRONE_LABELS, droneLabels.toTypedArray()) .apply { deviceIp?.let { putExtra(EXTRA_DEVICE_IP, it) } }, ) } diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/SwarmController.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/SwarmController.kt new file mode 100644 index 0000000..f12fe1f --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/SwarmController.kt @@ -0,0 +1,81 @@ +package com.px4.hawkeye.android.render + +/** + * Snapshot of the native wheel-gesture state machine plus the swarm session shape, as + * published by the render thread each frame (see `swarm_control.h`). Coordinates are in + * window-surface pixels, the same space the full-screen wheel overlay draws in. + */ +data class SwarmWheelSnapshot( + val phase: Int, + val centerX: Float, + val centerY: Float, + val fingerX: Float, + val fingerY: Float, + /** Bumped once per release-while-open; [releaseX]/[releaseY] hold that finger position. */ + val releaseSeq: Int, + val releaseX: Float, + val releaseY: Float, + val droneCount: Int, + val selected: Int, +) { + companion object { + // Mirrors wheel_phase_t in src/wheel_gesture.h. + const val PHASE_IDLE = 0 + const val PHASE_PENDING = 1 + const val PHASE_OPEN = 2 + const val PHASE_REJECTED = 3 + + val Idle = SwarmWheelSnapshot( + phase = PHASE_IDLE, + centerX = 0f, centerY = 0f, + fingerX = 0f, fingerY = 0f, + releaseSeq = 0, releaseX = 0f, releaseY = 0f, + droneCount = 0, selected = 0, + ) + } +} + +/** + * Controls drone selection in the native renderer. The implementation talks to C via JNI; + * tests use a fake. Calls are marshalled to the render thread through a lock-free control + * surface (see `swarm_control.h`). + */ +interface SwarmController { + fun wheel(): SwarmWheelSnapshot + fun selectDrone(index: Int) +} + +/** JNI-backed [SwarmController]. Only used inside the `:renderer` process. */ +class NativeSwarmController : SwarmController { + + override fun wheel(): SwarmWheelSnapshot { + val s = nativeGetWheel() + // [phase, centerX, centerY, fingerX, fingerY, releaseSeq, releaseX, releaseY, + // droneCount, selected] + if (s.size < 10) return SwarmWheelSnapshot.Idle + return SwarmWheelSnapshot( + phase = s[0].toInt(), + centerX = s[1], + centerY = s[2], + fingerX = s[3], + fingerY = s[4], + releaseSeq = s[5].toInt(), + releaseX = s[6], + releaseY = s[7], + droneCount = s[8].toInt(), + selected = s[9].toInt(), + ) + } + + override fun selectDrone(index: Int) = nativeSelectDrone(index) + + private external fun nativeGetWheel(): FloatArray + private external fun nativeSelectDrone(index: Int) + + 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/swarm/SwarmWheelAction.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelAction.kt new file mode 100644 index 0000000..c52f6b0 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelAction.kt @@ -0,0 +1,6 @@ +package com.px4.hawkeye.android.render.swarm + +sealed interface SwarmWheelAction { + /** The wheel released over a slice: make that drone the camera/HUD target. */ + data class OnDroneSelected(val index: Int) : SwarmWheelAction +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelScreen.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelScreen.kt new file mode 100644 index 0000000..12df692 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelScreen.kt @@ -0,0 +1,137 @@ +package com.px4.hawkeye.android.render.swarm + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.px4.hawkeye.android.R +import com.px4.hawkeye.android.render.SwarmWheelSnapshot +import com.px4.hawkeye.core.designsystem.HawkeyeDronePalette +import com.px4.hawkeye.core.designsystem.HawkeyeTheme +import com.px4.hawkeye.core.designsystem.wheel.WheelMenu +import com.px4.hawkeye.core.designsystem.wheel.WheelMenuItem +import com.px4.hawkeye.core.designsystem.wheel.WheelMenuState +import com.px4.hawkeye.core.designsystem.wheel.rememberWheelMenuState +import com.px4.hawkeye.core.presentation.UiText +import com.px4.hawkeye.core.presentation.asString + +/** + * Hosted in a dedicated full-screen, non-touchable WindowManager panel above the renderer + * (see HawkeyeActivity): the native engine owns the tap-and-hold gesture, this overlay + * only draws the wheel and resolves the released slice. + */ +@Composable +fun SwarmWheelRoot(viewModel: SwarmWheelViewModel) { + // The ViewModel owns the native-snapshot polling loop; the Root just collects state. + val state by viewModel.state.collectAsStateWithLifecycle() + SwarmWheelScreen(state = state, onAction = viewModel::onAction) +} + +@Composable +fun SwarmWheelScreen( + state: SwarmWheelState, + onAction: (SwarmWheelAction) -> Unit, +) { + // The wheel exists to switch between drones; single-drone (and empty) sessions keep + // the overlay fully inert, matching the native side never arming the gesture. + if (state.items.size < 2) return + + val resolvedItems = state.items.map { + WheelMenuItem( + label = it.label.asString(), + accentColor = it.accentColor, + hubLabel = it.hubLabel.asString(), + ) + } + val wheelState = rememberWheelMenuState(resolvedItems) + // Guarded swap, applied after composition (snapshot writes don't belong in the + // composition phase): assigning items while open clears the hover, so only push a + // real session change, not every recomposition's structurally-equal copy. + SideEffect { + if (wheelState.items != resolvedItems) wheelState.items = resolvedItems + } + + ProjectGestureIntoWheel(state.gesture, wheelState, onAction) + + WheelMenu( + state = wheelState, + selectHint = stringResource(R.string.swarm_wheel_hint), + ) +} + +/** + * Projects the polled native gesture snapshot into [WheelMenuState] — the "producer" the + * widget's contract asks for, with `wheel_gesture.h` as the source instead of a Compose + * pointerInput. Releases are processed before the phase branch on purpose: a release + * arrives with the native phase already back at IDLE, and handling the phase first would + * cancel the wheel and drop the selection. + */ +@Composable +private fun ProjectGestureIntoWheel( + gesture: SwarmWheelSnapshot, + wheelState: WheelMenuState, + onAction: (SwarmWheelAction) -> Unit, +) { + // Seed with the first-seen seq so an overlay (re)attached mid-session never replays + // a release that happened before it existed. + var handledReleaseSeq by remember { mutableIntStateOf(gesture.releaseSeq) } + + LaunchedEffect(gesture) { + if (gesture.releaseSeq != handledReleaseSeq) { + handledReleaseSeq = gesture.releaseSeq + if (wheelState.isOpen) { + wheelState.move(Offset(gesture.releaseX, gesture.releaseY)) + wheelState.release()?.let { onAction(SwarmWheelAction.OnDroneSelected(it)) } + } + // The native publish is not one atomic block, so this snapshot's phase can be + // a stale OPEN paired with the fresh seq. The release consumed the gesture; + // letting the phase branch run would reopen the wheel for one poll cycle. + return@LaunchedEffect + } + when (gesture.phase) { + SwarmWheelSnapshot.PHASE_OPEN -> { + if (!wheelState.isOpen) { + wheelState.open(Offset(gesture.centerX, gesture.centerY)) + } + wheelState.move(Offset(gesture.fingerX, gesture.fingerY)) + } + + else -> if (wheelState.isOpen) wheelState.cancel() + } + } +} + +// Static previews render the closed (empty) state — the wheel opens only through the +// native gesture projection. The open-wheel visuals are previewed in the design-system +// WheelMenu previews. +@Preview(showBackground = true, widthDp = 400, heightDp = 400) +@Composable +private fun SwarmWheelScreenPreview() { + HawkeyeTheme { + SwarmWheelScreen( + state = SwarmWheelState( + items = listOf( + DroneWheelItemUi( + label = UiText.DynamicString("Drone 1"), + hubLabel = UiText.DynamicString("alpha.ulg"), + accentColor = HawkeyeDronePalette.colors[0], + ), + DroneWheelItemUi( + label = UiText.DynamicString("Drone 2"), + hubLabel = UiText.DynamicString("bravo.ulg"), + accentColor = HawkeyeDronePalette.colors[1], + ), + ), + gesture = SwarmWheelSnapshot.Idle.copy(droneCount = 2), + ), + onAction = {}, + ) + } +} diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelState.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelState.kt new file mode 100644 index 0000000..edd9517 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelState.kt @@ -0,0 +1,22 @@ +package com.px4.hawkeye.android.render.swarm + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import com.px4.hawkeye.android.render.SwarmWheelSnapshot +import com.px4.hawkeye.core.presentation.UiText + +@Stable +data class SwarmWheelState( + /** One entry per drone, in staged order; empty until a session loads. */ + val items: List = emptyList(), + /** Latest native gesture/session snapshot driving the wheel widget. */ + val gesture: SwarmWheelSnapshot = SwarmWheelSnapshot.Idle, +) + +data class DroneWheelItemUi( + /** Slice label ("Drone N"), numbered to match the renderer's drone indicators. */ + val label: UiText, + /** Hub echo while hovered: the staged log's name, truncated to fit the hub. */ + val hubLabel: UiText, + val accentColor: Color, +) diff --git a/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelViewModel.kt b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelViewModel.kt new file mode 100644 index 0000000..1f9f4a5 --- /dev/null +++ b/android/app/src/main/java/com/px4/hawkeye/android/render/swarm/SwarmWheelViewModel.kt @@ -0,0 +1,92 @@ +package com.px4.hawkeye.android.render.swarm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.px4.hawkeye.android.R +import com.px4.hawkeye.android.render.SwarmController +import com.px4.hawkeye.android.render.SwarmWheelSnapshot +import com.px4.hawkeye.core.designsystem.HawkeyeDronePalette +import com.px4.hawkeye.core.presentation.UiText +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Backs the swarm drone-selection wheel overlay. Like the transport bar, state is a + * pull-based snapshot of the native engine: the ViewModel owns the polling cadence (fast + * while the native gesture machine owns a touch so the highlight tracks the finger, + * relaxed while idle) and [onAction] forwards the chosen drone to the [controller]. + * + * [droneLabels] are the staged logs' display names (Intent extra, staged order = drone + * order); items pair them with the shared drone palette so the wheel matches the meshes + * and trails the engine draws. + */ +class SwarmWheelViewModel( + private val controller: SwarmController, + private val droneLabels: List, +) : ViewModel() { + + private val _state = MutableStateFlow(SwarmWheelState()) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + while (isActive) { + refresh() + val idle = _state.value.gesture.phase == SwarmWheelSnapshot.PHASE_IDLE + delay(if (idle) IDLE_POLL_MS else ACTIVE_POLL_MS) + } + } + } + + fun refresh() { + val snapshot = controller.wheel() + _state.update { + it.copy( + items = if (snapshot.droneCount == it.items.size) it.items + else buildItems(snapshot.droneCount), + gesture = snapshot, + ) + } + } + + fun onAction(action: SwarmWheelAction) { + when (action) { + is SwarmWheelAction.OnDroneSelected -> controller.selectDrone(action.index) + } + } + + private fun buildItems(count: Int): List = List(count) { index -> + // Slices always read "Drone N" so they match the renderer's drone indicators; + // the staged log's name appears only in the hub, truncated to fit it. + val numbered = UiText.StringResource(R.string.swarm_drone_label, arrayOf(index + 1)) + val fileName = droneLabels.getOrNull(index)?.takeIf { it.isNotBlank() } + DroneWheelItemUi( + label = numbered, + hubLabel = fileName?.let { UiText.DynamicString(truncateForHub(it)) } ?: numbered, + accentColor = HawkeyeDronePalette.colors[index % HawkeyeDronePalette.colors.size], + ) + } + + companion object { + /** Poll cadence while the native gesture machine owns a touch (PENDING/OPEN). */ + const val ACTIVE_POLL_MS = 16L + + /** Poll cadence while idle: only needs to catch a hold starting. */ + const val IDLE_POLL_MS = 150L + + /** + * Longest hub text that fits the wheel hub on one line: the hub is 128 dp across + * and titleMedium glyphs average ~8 dp, so 12 characters plus the ellipsis stay + * inside the usable chord with padding to spare. + */ + const val HUB_LABEL_MAX_CHARS = 12 + + private fun truncateForHub(name: String): String = + if (name.length <= HUB_LABEL_MAX_CHARS) name + else name.take(HUB_LABEL_MAX_CHARS).trimEnd() + "…" + } +} 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 index 337504b..5196337 100644 --- 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 @@ -6,12 +6,12 @@ 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. + * time this runs the repository has already copied the selected logs into the renderer + * inbox and bumped the sentinel, so this only starts the renderer (which loads from the + * inbox) and forwards the drone labels for the swarm wheel overlay. */ class AndroidReplayPlaybackLauncher : ReplayPlaybackLauncher { - override fun launch(context: Context, entryId: String) { - RendererLauncher.launch(context) + override fun launch(context: Context, droneLabels: List) { + RendererLauncher.launch(context, droneLabels = droneLabels) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9f8a11d..817dfac 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -13,4 +13,9 @@ Waiting for MAVLink on %1$s Connected — sysid %1$d Connection lost + + + Release to switch + + Drone %1$d diff --git a/android/app/src/test/java/com/px4/hawkeye/android/render/swarm/FakeSwarmController.kt b/android/app/src/test/java/com/px4/hawkeye/android/render/swarm/FakeSwarmController.kt new file mode 100644 index 0000000..86d98e5 --- /dev/null +++ b/android/app/src/test/java/com/px4/hawkeye/android/render/swarm/FakeSwarmController.kt @@ -0,0 +1,16 @@ +package com.px4.hawkeye.android.render.swarm + +import com.px4.hawkeye.android.render.SwarmController +import com.px4.hawkeye.android.render.SwarmWheelSnapshot + +class FakeSwarmController : SwarmController { + var snapshot: SwarmWheelSnapshot = SwarmWheelSnapshot.Idle + + val selectedIndices = mutableListOf() + + override fun wheel(): SwarmWheelSnapshot = snapshot + + override fun selectDrone(index: Int) { + selectedIndices += index + } +} diff --git a/android/app/src/test/java/com/px4/hawkeye/android/render/swarm/SwarmWheelViewModelTest.kt b/android/app/src/test/java/com/px4/hawkeye/android/render/swarm/SwarmWheelViewModelTest.kt new file mode 100644 index 0000000..3dfe00b --- /dev/null +++ b/android/app/src/test/java/com/px4/hawkeye/android/render/swarm/SwarmWheelViewModelTest.kt @@ -0,0 +1,156 @@ +package com.px4.hawkeye.android.render.swarm + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import com.px4.hawkeye.android.render.SwarmWheelSnapshot +import com.px4.hawkeye.core.designsystem.HawkeyeDronePalette +import com.px4.hawkeye.core.presentation.UiText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +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 SwarmWheelViewModelTest { + + // Shared scheduler so the VM's viewModelScope poll loop can be advanced deterministically. + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + + @BeforeEach fun setUp() { Dispatchers.setMain(dispatcher) } + @AfterEach fun tearDown() { Dispatchers.resetMain() } + + private fun openSnapshot(fingerX: Float = 50f) = SwarmWheelSnapshot( + phase = SwarmWheelSnapshot.PHASE_OPEN, + centerX = 100f, centerY = 200f, + fingerX = fingerX, fingerY = 60f, + releaseSeq = 0, releaseX = 0f, releaseY = 0f, + droneCount = 2, selected = 0, + ) + + @Test + fun `self-polls the controller on the idle cadence`() { + val controller = FakeSwarmController().apply { + snapshot = SwarmWheelSnapshot.Idle.copy(droneCount = 2) + } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("a.ulg", "b.ulg")) + // The init loop runs one eager poll at construction. + assertThat(vm.state.value.gesture.droneCount).isEqualTo(2) + + // While idle, a change is picked up on the slow tick, not before. + controller.snapshot = controller.snapshot.copy(droneCount = 3) + scheduler.advanceTimeBy(SwarmWheelViewModel.ACTIVE_POLL_MS + 1) + assertThat(vm.state.value.gesture.droneCount).isEqualTo(2) + scheduler.advanceTimeBy(SwarmWheelViewModel.IDLE_POLL_MS + 1) + assertThat(vm.state.value.gesture.droneCount).isEqualTo(3) + } + + @Test + fun `polls on the fast cadence while the gesture is active`() { + val controller = FakeSwarmController().apply { snapshot = openSnapshot(fingerX = 10f) } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("a.ulg", "b.ulg")) + + controller.snapshot = openSnapshot(fingerX = 99f) + scheduler.advanceTimeBy(SwarmWheelViewModel.ACTIVE_POLL_MS + 1) + + assertThat(vm.state.value.gesture.fingerX).isEqualTo(99f) + } + + @Test + fun `slice labels are numbered to match the renderer's drone indicators`() { + val controller = FakeSwarmController().apply { + snapshot = SwarmWheelSnapshot.Idle.copy(droneCount = 2) + } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("alpha.ulg", "bravo.ulg")) + + val items = vm.state.value.items + // StringResource carries no structural equality; compare fields per item. + items.forEachIndexed { index, item -> + val label = item.label as UiText.StringResource + assertThat(label.id).isEqualTo(com.px4.hawkeye.android.R.string.swarm_drone_label) + assertThat(label.args.toList()).isEqualTo(listOf(index + 1)) + } + assertThat(items.map { it.accentColor }).containsExactly( + HawkeyeDronePalette.colors[0], + HawkeyeDronePalette.colors[1], + ) + } + + @Test + fun `hub labels carry the staged file names`() { + val controller = FakeSwarmController().apply { + snapshot = SwarmWheelSnapshot.Idle.copy(droneCount = 2) + } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("alpha.ulg", "bravo.ulg")) + + assertThat(vm.state.value.items.map { it.hubLabel }).containsExactly( + UiText.DynamicString("alpha.ulg"), + UiText.DynamicString("bravo.ulg"), + ) + } + + @Test + fun `long file names are truncated with an ellipsis for the hub`() { + val controller = FakeSwarmController().apply { + snapshot = SwarmWheelSnapshot.Idle.copy(droneCount = 3) + } + val vm = SwarmWheelViewModel( + controller, + droneLabels = listOf( + "flight_2026_05_28_long_name.ulg", // far past the cap: truncated + "exactly12chr", // at the cap: untouched + "thirteenchars", // one past the cap: truncated + ), + ) + + assertThat(vm.state.value.items.map { it.hubLabel }).containsExactly( + UiText.DynamicString("flight_2026_…"), + UiText.DynamicString("exactly12chr"), + UiText.DynamicString("thirteenchar…"), + ) + } + + @Test + fun `missing or blank file names fall back to the numbered drone name in the hub`() { + val controller = FakeSwarmController().apply { + snapshot = SwarmWheelSnapshot.Idle.copy(droneCount = 3) + } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("alpha.ulg", " ")) + + val hubLabels = vm.state.value.items.map { it.hubLabel } + assertThat(hubLabels[0]).isEqualTo(UiText.DynamicString("alpha.ulg")) + val blank = hubLabels[1] as UiText.StringResource + assertThat(blank.id).isEqualTo(com.px4.hawkeye.android.R.string.swarm_drone_label) + assertThat(blank.args.toList()).isEqualTo(listOf(2)) + val missing = hubLabels[2] as UiText.StringResource + assertThat(missing.id).isEqualTo(com.px4.hawkeye.android.R.string.swarm_drone_label) + assertThat(missing.args.toList()).isEqualTo(listOf(3)) + } + + @Test + fun `items truncate to the native drone count`() { + val controller = FakeSwarmController().apply { + snapshot = SwarmWheelSnapshot.Idle.copy(droneCount = 1) + } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("a.ulg", "b.ulg", "c.ulg")) + + assertThat(vm.state.value.items).hasSize(1) + } + + @Test + fun `selecting a drone forwards the index to the controller`() { + val controller = FakeSwarmController().apply { snapshot = openSnapshot() } + val vm = SwarmWheelViewModel(controller, droneLabels = listOf("a.ulg", "b.ulg")) + + vm.onAction(SwarmWheelAction.OnDroneSelected(1)) + + assertThat(controller.selectedIndices).containsExactly(1) + } +} diff --git a/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/DronePalette.kt b/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/DronePalette.kt new file mode 100644 index 0000000..65049da --- /dev/null +++ b/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/DronePalette.kt @@ -0,0 +1,31 @@ +package com.px4.hawkeye.core.designsystem + +import androidx.compose.ui.graphics.Color + +/** + * Per-drone accent colors for multi-drone (swarm) sessions, mirroring the native renderer's + * default theme `drone_palette` in `src/theme.c` so Compose chrome (wheel menu slices, labels) + * matches the drone meshes and trails drawn by the engine. Index = drone index; consumers wrap + * with `[i % colors.size]` exactly like the C side. Update both places together if the native + * default palette ever changes. + */ +object HawkeyeDronePalette { + val colors: List = listOf( + Color(230, 230, 230), // 0: white (primary) + Color(40, 120, 255), // 1: blue + Color(255, 40, 80), // 2: red + Color(255, 200, 40), // 3: yellow + Color(40, 220, 80), // 4: green + Color(255, 140, 0), // 5: orange + Color(180, 60, 255), // 6: purple + Color(255, 100, 160), // 7: pink + Color(0, 200, 200), // 8: teal + Color(255, 220, 100), // 9: gold + Color(100, 100, 255), // 10: indigo + Color(255, 180, 140), // 11: peach + Color(140, 255, 200), // 12: mint + Color(255, 60, 200), // 13: magenta + Color(140, 200, 255), // 14: sky blue + Color(200, 255, 60), // 15: lime + ) +} diff --git a/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenu.kt b/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenu.kt index c2176ae..5544974 100644 --- a/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenu.kt +++ b/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenu.kt @@ -148,7 +148,8 @@ fun WheelMenu( ) } - // Hub: hovered label echo + the release hint. + // Hub: hovered item echo (its hubLabel when set, else its label) + the + // release hint. drawCircle(color = style.hubColor, radius = hubR, center = center) drawCircle( color = style.borderColor, @@ -156,7 +157,8 @@ fun WheelMenu( center = center, style = Stroke(width = edgeW), ) - val hubLabel = hoveredIndex?.let { items.getOrNull(it)?.label } + val hubLabel = hoveredIndex?.let { items.getOrNull(it) } + ?.let { it.hubLabel ?: it.label } if (hubLabel != null) { val hub = textMeasurer.measure(hubLabel, hubStyle) drawText( diff --git a/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenuItem.kt b/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenuItem.kt index 740846c..9441ed4 100644 --- a/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenuItem.kt +++ b/android/core/design-system/src/main/kotlin/com/px4/hawkeye/core/designsystem/wheel/WheelMenuItem.kt @@ -12,10 +12,15 @@ import com.px4.hawkeye.core.designsystem.glassSurface * * @property label Slice text, drawn on a single line; keep it short (a word or two). * @property accentColor Color of the glyph dot above the label. + * @property hubLabel Optional detail text the hub echoes while this slice is hovered + * (e.g. a file name when the slice label is just an index); falls back to [label]. + * Drawn on a single line — the producer should pre-truncate anything that could + * outgrow the hub. */ data class WheelMenuItem( val label: String, val accentColor: Color, + val hubLabel: String? = null, ) /** diff --git a/android/core/design-system/src/test/kotlin/com/px4/hawkeye/core/designsystem/DronePaletteTest.kt b/android/core/design-system/src/test/kotlin/com/px4/hawkeye/core/designsystem/DronePaletteTest.kt new file mode 100644 index 0000000..ea079d2 --- /dev/null +++ b/android/core/design-system/src/test/kotlin/com/px4/hawkeye/core/designsystem/DronePaletteTest.kt @@ -0,0 +1,26 @@ +package com.px4.hawkeye.core.designsystem + +import androidx.compose.ui.graphics.Color +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import org.junit.jupiter.api.Test + +class DronePaletteTest { + + @Test + fun `palette has sixteen distinct colors`() { + assertThat(HawkeyeDronePalette.colors).hasSize(16) + assertThat(HawkeyeDronePalette.colors.distinct()).hasSize(16) + } + + @Test + fun `palette matches the native default theme anchors`() { + // Spot-check against src/theme.c default theme drone_palette to catch transcription + // drift: index 0 (primary white), 1 (blue), 5 (orange), 15 (lime). + assertThat(HawkeyeDronePalette.colors[0]).isEqualTo(Color(230, 230, 230)) + assertThat(HawkeyeDronePalette.colors[1]).isEqualTo(Color(40, 120, 255)) + assertThat(HawkeyeDronePalette.colors[5]).isEqualTo(Color(255, 140, 0)) + assertThat(HawkeyeDronePalette.colors[15]).isEqualTo(Color(200, 255, 60)) + } +} 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 index 874c74b..aad40a6 100644 --- 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 @@ -22,8 +22,9 @@ interface ReplayLibraryRepository { 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. + * Copies the library payloads for [ids] into the renderer's inbox (list order = drone + * order for a multi-drone session) and bumps the sentinel so the native poll loop loads + * them on the next launch. */ - suspend fun stageForPlayback(id: String): EmptyResult + suspend fun stageForPlayback(ids: List): 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 index 9ab00e0..8f40acc 100644 --- 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 @@ -10,5 +10,10 @@ import android.content.Context * library and the Home recents peek launch playback. */ fun interface ReplayPlaybackLauncher { - fun launch(context: Context, entryId: String) + /** + * Starts the renderer for the already-staged session. [droneLabels] are the staged logs' + * display names in drone order; the renderer shows them in its drone-selection wheel for + * multi-drone sessions. + */ + fun launch(context: Context, droneLabels: List) } 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 e7acec1..de26d64 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 @@ -7,8 +7,11 @@ sealed interface HomeEvent { /** Connect to a vehicle (simulated or real): launch the renderer in live mode. */ data object ConnectLive : HomeEvent - /** A recent log was staged into the inbox; hand off to the renderer. */ - data class PlayRecent(val entryId: String) : HomeEvent + /** + * A recent log was staged into the inbox; hand off to the renderer. [displayName] is + * forwarded as the session's single drone label. + */ + data class PlayRecent(val displayName: 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 98d05d8..f297d7f 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 @@ -63,7 +63,7 @@ fun HomeRoot( when (event) { HomeEvent.NavigateToReplay -> onNavigateToReplay() HomeEvent.ConnectLive -> onConnectLive() - is HomeEvent.PlayRecent -> playbackLauncher.launch(context, event.entryId) + is HomeEvent.PlayRecent -> playbackLauncher.launch(context, listOf(event.displayName)) is HomeEvent.ShowError -> Toast.makeText(context, event.text.asString(context), Toast.LENGTH_SHORT).show() } 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 5f79190..ce71841 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 @@ -42,8 +42,11 @@ class HomeViewModel( private fun playRecent(id: String) { viewModelScope.launch { - repository.stageForPlayback(id) - .onSuccess { _events.send(HomeEvent.PlayRecent(id)) } + repository.stageForPlayback(listOf(id)) + .onSuccess { + val name = _state.value.recents.find { it.id == id }?.displayName.orEmpty() + _events.send(HomeEvent.PlayRecent(displayName = name)) + } .onFailure { _events.send(HomeEvent.ShowError(it.toUiText())) } } } 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 index 9a1a0ce..00d7dd9 100644 --- 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 @@ -11,7 +11,7 @@ class FakeReplayLibraryRepository : ReplayLibraryRepository { val entriesFlow = MutableStateFlow>(emptyList()) var stageResult: EmptyResult = Result.Success(Unit) - val stagedIds = mutableListOf() + val stagedBatches = mutableListOf>() override fun observeLibrary() = entriesFlow @@ -20,8 +20,8 @@ class FakeReplayLibraryRepository : ReplayLibraryRepository { override suspend fun delete(id: String): EmptyResult = Result.Success(Unit) - override suspend fun stageForPlayback(id: String): EmptyResult { - stagedIds += id + override suspend fun stageForPlayback(ids: List): EmptyResult { + stagedBatches += ids 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 338126b..32d09c9 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 @@ -59,14 +59,14 @@ class HomeViewModelTest { } @Test - fun `recent click stages then emits PlayRecent`() = runTest { + fun `recent click stages then emits PlayRecent with the display name`() = 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(awaitItem()).isEqualTo(HomeEvent.PlayRecent(displayName = "a.ulg")) } - assertThat(repo.stagedIds).containsExactly("1") + assertThat(repo.stagedBatches).containsExactly(listOf("1")) } @Test 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 index 1c89de8..e9f5d09 100644 --- 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 @@ -23,7 +23,7 @@ class AndroidReplayFileManager( return store.write(input, fileName) } - override fun stage(fileName: String): EmptyResult = store.stage(fileName) + override fun stage(fileNames: List): EmptyResult = store.stage(fileNames) override fun delete(fileName: String) = store.delete(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 index 76c04b5..c7c2171 100644 --- 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 @@ -10,9 +10,9 @@ 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). + * staging copies them into `filesDir/inbox/` (`current.ulg`, plus `swarm_.ulg` for a + * multi-drone session) 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. @@ -41,18 +41,56 @@ class LibraryFileStore( ) /** 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") + fun stage(fileName: String): EmptyResult = stage(listOf(fileName)) + + /** + * Stages [fileNames] (staged order = drone order) into the inbox and bumps the sentinel. + * Index 0 keeps the legacy `current.ulg` name; indices 1..n-1 become `swarm_.ulg`. A + * single file writes the legacy bare-millis token; several write `" "` for + * the native swarm loader (an older binary's strtoll stops at the space and still reads + * the millis). + * + * A failed batch never clobbers the previous session: every payload is copied to a + * `.tmp` first and the live names are only renamed over (and stale extras deleted) + * after the whole batch has copied, so any I/O failure leaves the inbox exactly as the + * still-unchanged `.ready` token describes it. + */ + fun stage(fileNames: List): EmptyResult = runCatching { + if (fileNames.isEmpty()) throw FileNotFoundException("empty stage batch") + val sources = fileNames.map { name -> + File(libraryDir, name).also { + if (!it.exists()) throw FileNotFoundException("missing library file $name") + } + } 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") + val targets = List(fileNames.size) { index -> + File(inboxDir, if (index == 0) "current.ulg" else "swarm_$index.ulg") } - File(inboxDir, ".ready").writeText(clock().toString()) + val tmps = targets.map { File(inboxDir, "${it.name}.tmp") } + try { + sources.forEachIndexed { index, source -> + source.inputStream().use { input -> + tmps[index].outputStream().use { output -> input.copyTo(output) } + } + } + } catch (e: Throwable) { + tmps.forEach { it.delete() } + throw e + } + // The whole batch is on disk; same-filesystem renames don't fail for space. + tmps.forEachIndexed { index, tmp -> + if (!tmp.renameTo(targets[index])) { + tmps.forEach { it.delete() } + throw IOException("renameTo ${targets[index]} failed") + } + } + // Drop swarm payloads beyond the new batch only after it is fully in place. + inboxDir.listFiles { file -> SWARM_FILE_PATTERN.matches(file.name) } + ?.filterNot { it in targets } + ?.forEach { it.delete() } + val token = clock().toString() + File(inboxDir, ".ready") + .writeText(if (fileNames.size == 1) token else "$token ${fileNames.size}") }.fold( onSuccess = { Result.Success(Unit) }, onFailure = { Result.Error(classify(it)) }, @@ -71,4 +109,9 @@ class LibraryFileStore( DataError.Local.DISK_FULL else -> DataError.Local.UNKNOWN } + + private companion object { + /** Extra swarm payloads (and their tmp files) from a previous multi-drone session. */ + val SWARM_FILE_PATTERN = Regex("""swarm_\d+\.ulg(\.tmp)?""") + } } 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 index 335454c..1c484e0 100644 --- 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 @@ -13,8 +13,11 @@ 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 + /** + * Copies the library payloads [fileNames] (order = drone order) into the renderer inbox + * and bumps the sentinel. + */ + fun stage(fileNames: List): EmptyResult /** Removes the library payload [fileName]. */ fun delete(fileName: 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 index dd3c8b1..5183ae3 100644 --- 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 @@ -70,11 +70,17 @@ class RoomReplayLibraryRepository( }.getOrElse { Result.Error(it.toLocalError()) } } - override suspend fun stageForPlayback(id: String): EmptyResult = + override suspend fun stageForPlayback(ids: List): EmptyResult = withContext(ioDispatcher) { runCatching { - val row = dao.getById(id) - if (row == null) Result.Error(DataError.Local.NOT_FOUND) else fileManager.stage(row.fileName) + // Resolve every id (preserving caller order — it becomes the drone order) + // before staging anything, so a bad id never disturbs the current inbox. + val fileNames = ids.map { id -> dao.getById(id)?.fileName } + if (fileNames.isEmpty() || fileNames.any { it == null }) { + Result.Error(DataError.Local.NOT_FOUND) + } else { + fileManager.stage(fileNames.filterNotNull()) + } }.getOrElse { Result.Error(it.toLocalError()) } } 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 index 800abcd..2872b58 100644 --- 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 @@ -11,15 +11,15 @@ class FakeReplayFileManager : ReplayFileManager { val importedFileNames = mutableListOf() val deletedFileNames = mutableListOf() - val stagedFileNames = mutableListOf() + val stagedBatches = mutableListOf>() override fun import(uri: String, fileName: String): Result { importedFileNames += fileName return importResult } - override fun stage(fileName: String): EmptyResult { - stagedFileNames += fileName + override fun stage(fileNames: List): EmptyResult { + stagedBatches += fileNames return stageResult } 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 index eafdc6b..753f852 100644 --- 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 @@ -62,6 +62,98 @@ class LibraryFileStoreTest { assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) } + @Test + fun `staging several files writes current plus swarm files in order with a count token`(@TempDir dir: File) { + val store = store(dir, now = 4_242L) + store.write(ByteArrayInputStream("alpha".toByteArray()), "a.ulg") + store.write(ByteArrayInputStream("bravo".toByteArray()), "b.ulg") + store.write(ByteArrayInputStream("charlie".toByteArray()), "c.ulg") + + val result = store.stage(listOf("a.ulg", "b.ulg", "c.ulg")) + + assertThat(result).isEqualTo(Result.Success(Unit)) + val inbox = File(dir, "inbox") + assertThat(File(inbox, "current.ulg").readText()).isEqualTo("alpha") + assertThat(File(inbox, "swarm_1.ulg").readText()).isEqualTo("bravo") + assertThat(File(inbox, "swarm_2.ulg").readText()).isEqualTo("charlie") + assertThat(File(inbox, ".ready").readText()).isEqualTo("4242 3") + } + + @Test + fun `staging a single-element list keeps the legacy token format`(@TempDir dir: File) { + val store = store(dir, now = 4_242L) + store.write(ByteArrayInputStream("alpha".toByteArray()), "a.ulg") + + val result = store.stage(listOf("a.ulg")) + + assertThat(result).isEqualTo(Result.Success(Unit)) + val inbox = File(dir, "inbox") + assertThat(File(inbox, "current.ulg").readText()).isEqualTo("alpha") + assertThat(File(inbox, ".ready").readText()).isEqualTo("4242") + } + + @Test + fun `staging fewer files removes stale swarm files from a previous session`(@TempDir dir: File) { + val store = store(dir) + store.write(ByteArrayInputStream("alpha".toByteArray()), "a.ulg") + store.write(ByteArrayInputStream("bravo".toByteArray()), "b.ulg") + store.write(ByteArrayInputStream("charlie".toByteArray()), "c.ulg") + store.stage(listOf("a.ulg", "b.ulg", "c.ulg")) + + store.stage(listOf("c.ulg", "a.ulg")) + + val inbox = File(dir, "inbox") + assertThat(File(inbox, "current.ulg").readText()).isEqualTo("charlie") + assertThat(File(inbox, "swarm_1.ulg").readText()).isEqualTo("alpha") + assertThat(File(inbox, "swarm_2.ulg").exists()).isFalse() + } + + @Test + fun `multi-staging with any missing file fails without touching the inbox`(@TempDir dir: File) { + val store = store(dir, now = 4_242L) + store.write(ByteArrayInputStream("alpha".toByteArray()), "a.ulg") + store.stage("a.ulg") + + val later = LibraryFileStore(dir, clock = { 9_999L }) + val result = later.stage(listOf("a.ulg", "ghost.ulg")) + + assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + val inbox = File(dir, "inbox") + assertThat(File(inbox, "current.ulg").readText()).isEqualTo("alpha") + assertThat(File(inbox, ".ready").readText()).isEqualTo("4242") + assertThat(File(inbox, "swarm_1.ulg").exists()).isFalse() + } + + @Test + fun `a copy failure mid-batch leaves the previous inbox fully intact`(@TempDir dir: File) { + val store = store(dir, now = 4_242L) + store.write(ByteArrayInputStream("old-current".toByteArray()), "old.ulg") + store.write(ByteArrayInputStream("old-swarm".toByteArray()), "old2.ulg") + store.stage(listOf("old.ulg", "old2.ulg")) + // A directory passes the exists() pre-check but fails to open as a stream, + // simulating an I/O failure after the first file of the batch copied. + store.write(ByteArrayInputStream("good".toByteArray()), "good.ulg") + File(File(dir, "library"), "broken.ulg").mkdirs() + + val later = LibraryFileStore(dir, clock = { 9_999L }) + val result = later.stage(listOf("good.ulg", "broken.ulg")) + + assertThat(result is Result.Error).isTrue() + val inbox = File(dir, "inbox") + assertThat(File(inbox, "current.ulg").readText()).isEqualTo("old-current") + assertThat(File(inbox, "swarm_1.ulg").readText()).isEqualTo("old-swarm") + assertThat(File(inbox, ".ready").readText()).isEqualTo("4242 2") + assertThat(inbox.listFiles()!!.none { it.name.endsWith(".tmp") }).isTrue() + } + + @Test + fun `staging an empty list fails with NOT_FOUND`(@TempDir dir: File) { + val result = store(dir).stage(emptyList()) + + assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + assertThat(File(File(dir, "inbox"), ".ready").exists()).isFalse() + } + @Test fun `delete removes the library file`(@TempDir dir: File) { val store = store(dir) 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 index fd9c6eb..0587f86 100644 --- 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 @@ -106,17 +106,37 @@ class RoomReplayLibraryRepositoryTest { fun `stageForPlayback stages the entry's payload`() = runTest { dao.seed(entity("a")) - val result = repository().stageForPlayback("a") + val result = repository().stageForPlayback(listOf("a")) assertThat(result).isEqualTo(Result.Success(Unit)) - assertThat(files.stagedFileNames).containsExactly("a.ulg") + assertThat(files.stagedBatches).containsExactly(listOf("a.ulg")) + } + + @Test + fun `stageForPlayback resolves payloads preserving the caller's id order`() = runTest { + dao.seed(entity("a"), entity("b"), entity("c")) + + val result = repository().stageForPlayback(listOf("b", "a", "c")) + + assertThat(result).isEqualTo(Result.Success(Unit)) + assertThat(files.stagedBatches).containsExactly(listOf("b.ulg", "a.ulg", "c.ulg")) } @Test fun `stageForPlayback returns NOT_FOUND for an unknown id`() = runTest { - val result = repository().stageForPlayback("missing") + val result = repository().stageForPlayback(listOf("missing")) + + assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + } + + @Test + fun `stageForPlayback with any unknown id fails before staging anything`() = runTest { + dao.seed(entity("a")) + + val result = repository().stageForPlayback(listOf("a", "missing")) assertThat(result).isEqualTo(Result.Error(DataError.Local.NOT_FOUND)) + assertThat(files.stagedBatches).isEqualTo(emptyList>()) } private fun entity(id: String, importedAt: Long = 0L) = LibraryEntryEntity( 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 index 6257193..1e88c4f 100644 --- 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 @@ -7,4 +7,10 @@ sealed interface ReplayLibraryAction { data class OnDeleteRequested(val id: String) : ReplayLibraryAction data object OnConfirmDelete : ReplayLibraryAction data object OnDismissDelete : ReplayLibraryAction + + /** Enter/exit multi-select; exiting clears the current selection. */ + data object OnToggleSelectionMode : ReplayLibraryAction + + /** Stage the selected logs as one swarm session and launch the renderer. */ + data object OnPlayTogetherClicked : 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 index 05f9f81..4023cb2 100644 --- 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 @@ -6,8 +6,15 @@ 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 + /** + * One or more logs were staged into the inbox; hand off to the renderer. + * [droneLabels] are the logs' display names in staged (drone) order, shown by the + * renderer's drone-selection wheel. + */ + data class LaunchReplay( + val entryIds: List, + val droneLabels: List, + ) : 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 index ee1a018..3320005 100644 --- 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 @@ -7,16 +7,19 @@ 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.Row 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.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,6 +38,7 @@ 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.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import com.px4.hawkeye.core.designsystem.HawkeyeAlpha @@ -63,7 +67,7 @@ fun ReplayLibraryRoot( ObserveAsEvents(viewModel.events) { event -> when (event) { ReplayLibraryEvent.LaunchFilePicker -> pickFile.launch(arrayOf("*/*")) - is ReplayLibraryEvent.LaunchReplay -> playbackLauncher.launch(context, event.entryId) + is ReplayLibraryEvent.LaunchReplay -> playbackLauncher.launch(context, event.droneLabels) is ReplayLibraryEvent.ShowError -> Toast.makeText(context, event.text.asString(context), Toast.LENGTH_SHORT).show() } @@ -79,26 +83,66 @@ fun ReplayLibraryScreen( onAction: (ReplayLibraryAction) -> Unit, onBack: () -> Unit, ) { + // System back leaves selection mode first (standard selection UX); only a second + // back navigates away from the library. + BackHandler(enabled = state.isSelectionMode) { + onAction(ReplayLibraryAction.OnToggleSelectionMode) + } + Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.replay_library_title)) }, + title = { + Text( + if (state.isSelectionMode) { + stringResource(R.string.replay_selection_title, state.selectedIds.size) + } else { + stringResource(R.string.replay_library_title) + }, + ) + }, navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.replay_back), - ) + if (state.isSelectionMode) { + IconButton(onClick = { onAction(ReplayLibraryAction.OnToggleSelectionMode) }) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.replay_exit_selection), + ) + } + } else { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.replay_back), + ) + } + } + }, + actions = { + if (!state.isSelectionMode && state.entries.size >= 2) { + TextButton(onClick = { onAction(ReplayLibraryAction.OnToggleSelectionMode) }) { + Text(stringResource(R.string.replay_select)) + } } }, ) }, floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { onAction(ReplayLibraryAction.OnOpenFileClicked) }, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) { Text(stringResource(R.string.replay_open_file)) } + if (state.isSelectionMode) { + if (state.selectedIds.size >= 2) { + ExtendedFloatingActionButton( + onClick = { onAction(ReplayLibraryAction.OnPlayTogetherClicked) }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { Text(stringResource(R.string.replay_play_together, state.selectedIds.size)) } + } + } else { + ExtendedFloatingActionButton( + onClick = { onAction(ReplayLibraryAction.OnOpenFileClicked) }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { Text(stringResource(R.string.replay_open_file)) } + } }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { @@ -109,7 +153,12 @@ fun ReplayLibraryScreen( state.entries.isEmpty() -> EmptyState(modifier = Modifier.align(Alignment.Center)) - else -> LibraryList(entries = state.entries, onAction = onAction) + else -> LibraryList( + entries = state.entries, + isSelectionMode = state.isSelectionMode, + selectedIds = state.selectedIds, + onAction = onAction, + ) } if (state.isImporting) { @@ -132,6 +181,8 @@ fun ReplayLibraryScreen( @Composable private fun LibraryList( entries: List, + isSelectionMode: Boolean, + selectedIds: List, onAction: (ReplayLibraryAction) -> Unit, ) { LazyColumn( @@ -141,8 +192,14 @@ private fun LibraryList( items(items = entries, key = { it.id }) { entry -> LibraryRow( entry = entry, + isSelectionMode = isSelectionMode, + isSelected = entry.id in selectedIds, onClick = { onAction(ReplayLibraryAction.OnEntryClicked(entry.id)) }, - onLongClick = { onAction(ReplayLibraryAction.OnDeleteRequested(entry.id)) }, + // Long-press keeps its delete meaning only outside selection mode, so a + // sloppy selection tap can never surface the destructive dialog. + onLongClick = { + if (!isSelectionMode) onAction(ReplayLibraryAction.OnDeleteRequested(entry.id)) + }, ) } } @@ -152,10 +209,12 @@ private fun LibraryList( @Composable private fun LibraryRow( entry: LibraryEntryUi, + isSelectionMode: Boolean, + isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, ) { - Column( + Row( modifier = Modifier .fillMaxWidth() .combinedClickable(onClick = onClick, onLongClick = onLongClick) @@ -163,17 +222,29 @@ private fun LibraryRow( horizontal = HawkeyeDimens.contentPadding, vertical = HawkeyeDimens.itemSpacing, ), + verticalAlignment = Alignment.CenterVertically, ) { - 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), - ) + if (isSelectionMode) { + Checkbox( + checked = isSelected, + // Row-level combinedClickable owns the toggle so checkbox and row behave + // identically; a null handler keeps the checkbox purely visual. + onCheckedChange = null, + modifier = Modifier.padding(end = HawkeyeDimens.inlineSpacing), + ) + } + Column { + 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), + ) + } } } @@ -234,6 +305,27 @@ private fun ReplayLibraryScreenPreview() { } } +@Preview(showBackground = true) +@Composable +private fun ReplayLibrarySelectionPreview() { + HawkeyeTheme { + ReplayLibraryScreen( + state = ReplayLibraryState( + isLoading = false, + isSelectionMode = true, + selectedIds = listOf("1", "2"), + 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"), + LibraryEntryUi("3", "hover_check.ulg", "1.8 MB", "May 26, 2026"), + ), + ), + onAction = {}, + onBack = {}, + ) + } +} + @Preview(showBackground = true) @Composable private fun ReplayLibraryEmptyPreview() { 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 index 464c050..b6a263f 100644 --- 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 @@ -8,6 +8,9 @@ data class ReplayLibraryState( val isLoading: Boolean = true, val isImporting: Boolean = false, val pendingDelete: LibraryEntryUi? = null, + val isSelectionMode: Boolean = false, + /** Click order, not list order: it becomes the swarm's drone order. */ + val selectedIds: List = emptyList(), ) /** Presentation view of a library entry, with size/date already formatted for display. */ 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 index 7c88bf1..2ddec61 100644 --- 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 @@ -4,6 +4,7 @@ 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.core.presentation.toUiText import com.px4.hawkeye.core.domain.LibraryEntry import com.px4.hawkeye.core.domain.ReplayLibraryRepository @@ -39,7 +40,9 @@ class ReplayLibraryViewModel( is ReplayLibraryAction.OnFilePicked -> action.uri?.let(::importFile) - is ReplayLibraryAction.OnEntryClicked -> stageAndLaunch(action.id) + is ReplayLibraryAction.OnEntryClicked -> + if (_state.value.isSelectionMode) toggleSelection(action.id) + else stageAndLaunch(listOf(action.id)) is ReplayLibraryAction.OnDeleteRequested -> _state.update { state -> state.copy(pendingDelete = state.entries.find { it.id == action.id }) } @@ -48,9 +51,41 @@ class ReplayLibraryViewModel( ReplayLibraryAction.OnDismissDelete -> _state.update { it.copy(pendingDelete = null) } + + ReplayLibraryAction.OnToggleSelectionMode -> + _state.update { + it.copy(isSelectionMode = !it.isSelectionMode, selectedIds = emptyList()) + } + + ReplayLibraryAction.OnPlayTogetherClicked -> playTogether() } } + private fun toggleSelection(id: String) { + val selected = _state.value.selectedIds + when { + id in selected -> + _state.update { it.copy(selectedIds = it.selectedIds - id) } + + selected.size >= MAX_SWARM -> + viewModelScope.launch { + _events.send( + ReplayLibraryEvent.ShowError( + UiText.StringResource(R.string.replay_swarm_cap), + ), + ) + } + + else -> _state.update { it.copy(selectedIds = it.selectedIds + id) } + } + } + + private fun playTogether() { + val ids = _state.value.selectedIds + if (ids.size < 2) return + stageAndLaunch(ids) + } + private fun importFile(uri: String) { viewModelScope.launch { _state.update { it.copy(isImporting = true) } @@ -59,10 +94,16 @@ class ReplayLibraryViewModel( } } - private fun stageAndLaunch(id: String) { + private fun stageAndLaunch(ids: List) { viewModelScope.launch { - repository.stageForPlayback(id) - .onSuccess { _events.send(ReplayLibraryEvent.LaunchReplay(id)) } + repository.stageForPlayback(ids) + .onSuccess { + val labels = ids.map { id -> + _state.value.entries.find { it.id == id }?.displayName.orEmpty() + } + _state.update { it.copy(isSelectionMode = false, selectedIds = emptyList()) } + _events.send(ReplayLibraryEvent.LaunchReplay(ids, labels)) + } .onFailure { _events.send(ReplayLibraryEvent.ShowError(it.toUiText())) } } } @@ -74,4 +115,9 @@ class ReplayLibraryViewModel( repository.delete(pending.id).onFailure { _events.send(ReplayLibraryEvent.ShowError(it.toUiText())) } } } + + companion object { + /** Mirrors the native renderer's MAX_SWARM_VEHICLES (android_main.c). */ + const val MAX_SWARM = 16 + } } 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 765b581..aaff0d5 100644 --- a/android/feature/replay/presentation/src/main/res/values/strings.xml +++ b/android/feature/replay/presentation/src/main/res/values/strings.xml @@ -14,4 +14,10 @@ Remove \"%1$s\" from the library? This cannot be undone. Delete Cancel + + Select + Exit selection + %1$d selected + Play together (%1$d) + Up to 16 logs can play together 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 index a484eeb..183ce67 100644 --- 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 @@ -17,7 +17,7 @@ class FakeReplayLibraryRepository : ReplayLibraryRepository { var deleteResult: EmptyResult = Result.Success(Unit) val importedUris = mutableListOf() - val stagedIds = mutableListOf() + val stagedBatches = mutableListOf>() val deletedIds = mutableListOf() override fun observeLibrary() = entriesFlow @@ -35,8 +35,8 @@ class FakeReplayLibraryRepository : ReplayLibraryRepository { return deleteResult } - override suspend fun stageForPlayback(id: String): EmptyResult { - stagedIds += id + override suspend fun stageForPlayback(ids: List): EmptyResult { + stagedBatches += ids return stageResult } } 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 index ce313d3..76ed9b9 100644 --- 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 @@ -7,6 +7,7 @@ import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isInstanceOf import assertk.assertions.isNull +import assertk.assertions.isTrue import com.px4.hawkeye.core.domain.DataError import com.px4.hawkeye.core.domain.Result import com.px4.hawkeye.core.domain.LibraryEntry @@ -102,13 +103,15 @@ class ReplayLibraryViewModelTest { @Test fun `OnEntryClicked stages then emits LaunchReplay`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("42", "flight.ulg", 1L, 0L)) val vm = ReplayLibraryViewModel(repo) vm.events.test { vm.onAction(ReplayLibraryAction.OnEntryClicked("42")) - assertThat(awaitItem()).isEqualTo(ReplayLibraryEvent.LaunchReplay("42")) + assertThat(awaitItem()) + .isEqualTo(ReplayLibraryEvent.LaunchReplay(listOf("42"), listOf("flight.ulg"))) } - assertThat(repo.stagedIds).containsExactly("42") + assertThat(repo.stagedBatches).containsExactly(listOf("42")) } @Test @@ -122,6 +125,117 @@ class ReplayLibraryViewModelTest { } } + @Test + fun `toggling selection mode on and off clears the selection`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 1L, 0L)) + val vm = ReplayLibraryViewModel(repo) + + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + assertThat(vm.state.value.isSelectionMode).isTrue() + + vm.onAction(ReplayLibraryAction.OnEntryClicked("1")) + assertThat(vm.state.value.selectedIds).containsExactly("1") + + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + assertThat(vm.state.value.isSelectionMode).isFalse() + assertThat(vm.state.value.selectedIds).isEqualTo(emptyList()) + } + + @Test + fun `entry clicks in selection mode toggle membership preserving click order`() = runTest { + repo.entriesFlow.value = listOf( + LibraryEntry("1", "a.ulg", 1L, 0L), + LibraryEntry("2", "b.ulg", 1L, 0L), + LibraryEntry("3", "c.ulg", 1L, 0L), + ) + val vm = ReplayLibraryViewModel(repo) + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + + vm.onAction(ReplayLibraryAction.OnEntryClicked("2")) + vm.onAction(ReplayLibraryAction.OnEntryClicked("1")) + vm.onAction(ReplayLibraryAction.OnEntryClicked("3")) + assertThat(vm.state.value.selectedIds).containsExactly("2", "1", "3") + + vm.onAction(ReplayLibraryAction.OnEntryClicked("1")) + assertThat(vm.state.value.selectedIds).containsExactly("2", "3") + assertThat(repo.stagedBatches).isEqualTo(emptyList>()) + } + + @Test + fun `selecting past the swarm cap is ignored and reports an error`() = runTest { + repo.entriesFlow.value = (1..17).map { LibraryEntry("$it", "log$it.ulg", 1L, 0L) } + val vm = ReplayLibraryViewModel(repo) + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + + vm.events.test { + (1..16).forEach { vm.onAction(ReplayLibraryAction.OnEntryClicked("$it")) } + expectNoEvents() + + vm.onAction(ReplayLibraryAction.OnEntryClicked("17")) + assertThat(awaitItem()).isInstanceOf(ReplayLibraryEvent.ShowError::class) + } + assertThat(vm.state.value.selectedIds).isEqualTo((1..16).map { "$it" }) + } + + @Test + fun `play together stages the selection in order and launches with display names`() = runTest { + repo.entriesFlow.value = listOf( + LibraryEntry("1", "alpha.ulg", 1L, 0L), + LibraryEntry("2", "bravo.ulg", 1L, 0L), + LibraryEntry("3", "charlie.ulg", 1L, 0L), + ) + val vm = ReplayLibraryViewModel(repo) + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + vm.onAction(ReplayLibraryAction.OnEntryClicked("3")) + vm.onAction(ReplayLibraryAction.OnEntryClicked("1")) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnPlayTogetherClicked) + assertThat(awaitItem()).isEqualTo( + ReplayLibraryEvent.LaunchReplay( + entryIds = listOf("3", "1"), + droneLabels = listOf("charlie.ulg", "alpha.ulg"), + ), + ) + } + assertThat(repo.stagedBatches).containsExactly(listOf("3", "1")) + assertThat(vm.state.value.isSelectionMode).isFalse() + assertThat(vm.state.value.selectedIds).isEqualTo(emptyList()) + } + + @Test + fun `play together stage failure reports an error and keeps the selection`() = runTest { + repo.entriesFlow.value = listOf( + LibraryEntry("1", "a.ulg", 1L, 0L), + LibraryEntry("2", "b.ulg", 1L, 0L), + ) + repo.stageResult = Result.Error(DataError.Local.NOT_FOUND) + val vm = ReplayLibraryViewModel(repo) + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + vm.onAction(ReplayLibraryAction.OnEntryClicked("1")) + vm.onAction(ReplayLibraryAction.OnEntryClicked("2")) + + vm.events.test { + vm.onAction(ReplayLibraryAction.OnPlayTogetherClicked) + assertThat(awaitItem()).isInstanceOf(ReplayLibraryEvent.ShowError::class) + } + assertThat(vm.state.value.isSelectionMode).isTrue() + assertThat(vm.state.value.selectedIds).containsExactly("1", "2") + } + + @Test + fun `play together with fewer than two selections does nothing`() = runTest { + repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 1L, 0L)) + val vm = ReplayLibraryViewModel(repo) + vm.onAction(ReplayLibraryAction.OnToggleSelectionMode) + vm.onAction(ReplayLibraryAction.OnEntryClicked("1")) + + vm.onAction(ReplayLibraryAction.OnPlayTogetherClicked) + + assertThat(repo.stagedBatches).isEqualTo(emptyList>()) + assertThat(vm.state.value.isSelectionMode).isTrue() + } + @Test fun `delete request then confirm deletes and clears the pending entry`() = runTest { repo.entriesFlow.value = listOf(LibraryEntry("1", "a.ulg", 1L, 0L))