Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":core:design-system"))
implementation(project(":feature:replay:domain"))
implementation(project(":feature:replay:data"))
implementation(project(":feature:replay:presentation"))
implementation(project(":core:navigation"))
Expand Down Expand Up @@ -80,6 +79,9 @@ dependencies {
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.assertk)

androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.px4.hawkeye.android

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity
import com.px4.hawkeye.feature.replay.data.db.ReplayLibraryDao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.GlobalContext

/**
* Seeds several library entries directly into the real Room DAO (exercising the live
* `ORDER BY imported_at_millis DESC` query), then verifies multi-item behavior on a
* device: the Home peek shows only the three newest while the Replay library shows every
* entry, and the library keeps its items across rotation.
*
* Uses an empty compose rule so seeding happens before the activity launches, making the
* first library emission deterministic. The DAO is resolved from the app's Koin graph.
*/
@RunWith(AndroidJUnit4::class)
class LibraryRecentsMultiItemTest {

@get:Rule
val composeRule = createEmptyComposeRule()

private val dao: ReplayLibraryDao get() = GlobalContext.get().get()

@Before
fun seed() = runBlocking {
clearLibrary()
SEED.forEach { dao.insert(it) }
}

@After
fun cleanup() = runBlocking { clearLibrary() }

private suspend fun clearLibrary() {
dao.observeAll().first().forEach { dao.deleteById(it.id) }
}

@Test
fun homeShowsTheThreeNewestRecents() {
ActivityScenario.launch(MainActivity::class.java).use {
awaitText(NEWEST)
composeRule.onNodeWithText(NEWEST).assertIsDisplayed()
composeRule.onNodeWithText(SECOND).assertIsDisplayed()
composeRule.onNodeWithText(THIRD).assertIsDisplayed()
// The fourth (oldest) entry is beyond the three-item Home peek.
composeRule.onNodeWithText(OLDEST).assertDoesNotExist()
}
}

@Test
fun replayLibraryShowsEveryItem() {
ActivityScenario.launch(MainActivity::class.java).use {
composeRule.onNodeWithText(HOME_REPLAY_CARD).performClick()
awaitText(OLDEST)
listOf(NEWEST, SECOND, THIRD, OLDEST).forEach {
composeRule.onNodeWithText(it).assertIsDisplayed()
}
}
}

@Test
fun replayLibraryKeepsItemsAcrossRotation() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
composeRule.onNodeWithText(HOME_REPLAY_CARD).performClick()
awaitText(OLDEST)

scenario.recreate()
composeRule.waitForIdle()

awaitText(OLDEST)
composeRule.onNodeWithText(NEWEST).assertIsDisplayed()
}
}

private fun awaitText(text: String) {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}

private companion object {
const val NEWEST = "newest.ulg"
const val SECOND = "second.ulg"
const val THIRD = "third.ulg"
const val OLDEST = "oldest.ulg"
const val HOME_REPLAY_CARD = "Replay a flight"

// Same payload, different names/timestamps. Newest first by imported-at millis.
val SEED = listOf(
LibraryEntryEntity("e1", NEWEST, 1024L, 40L, "e1.ulg"),
LibraryEntryEntity("e2", SECOND, 1024L, 30L, "e2.ulg"),
LibraryEntryEntity("e3", THIRD, 1024L, 20L, "e3.ulg"),
LibraryEntryEntity("e4", OLDEST, 1024L, 10L, "e4.ulg"),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.px4.hawkeye.android

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Drives the Compose shell on a device: navigates to every destination and rotates each
* one. Rotation is exercised with [androidx.test.core.app.ActivityScenario.recreate],
* which runs the same activity destroy/recreate + state-restoration path a real
* orientation change triggers. Each screen must survive it (no crash, content intact),
* and the selected destination must be preserved (it lives in ShellViewModel).
*
* The native renderer (HawkeyeActivity) is intentionally out of scope here: it runs in a
* separate `:renderer` process, is landscape-locked, and hard-exits on teardown.
*/
@RunWith(AndroidJUnit4::class)
class ShellNavigationTest {

@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()

private fun rotate() {
composeRule.activityRule.scenario.recreate()
composeRule.waitForIdle()
}

@Test
fun home_isShown_andSurvivesRotation() {
composeRule.onNodeWithText(HOME_REPLAY_CARD).assertIsDisplayed()
rotate()
composeRule.onNodeWithText(HOME_REPLAY_CARD).assertIsDisplayed()
}

@Test
fun replayLibrary_isReachableFromHome_andSurvivesRotation() {
composeRule.onNodeWithText(HOME_REPLAY_CARD).performClick()
composeRule.onNodeWithText(REPLAY_LIBRARY_TITLE).assertIsDisplayed()
rotate()
composeRule.onNodeWithText(REPLAY_LIBRARY_TITLE).assertIsDisplayed()
}

@Test
fun settings_isReachableFromNavBar_andSurvivesRotation() {
composeRule.onNodeWithText(NAV_SETTINGS).performClick()
composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed()
rotate()
composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed()
}

@Test
fun live_isReachableFromHome_andSurvivesRotation() {
composeRule.onNodeWithText(HOME_CONNECT_CARD).performClick()
composeRule.onNodeWithText(LIVE_COMING_SOON).assertIsDisplayed()
rotate()
composeRule.onNodeWithText(LIVE_COMING_SOON).assertIsDisplayed()
}

@Test
fun selectedDestination_isPreservedAcrossRotation() {
composeRule.onNodeWithText(NAV_SETTINGS).performClick()
composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed()

rotate()

// Still on Settings — not reset to Home — proving the back stack in ShellViewModel
// survived the config change.
composeRule.onNodeWithText(SETTINGS_THEME_HEADER).assertIsDisplayed()
}

private companion object {
const val HOME_REPLAY_CARD = "Replay a flight"
const val HOME_CONNECT_CARD = "Connect to a simulator"
const val REPLAY_LIBRARY_TITLE = "Replay library"
const val NAV_SETTINGS = "Settings"
const val SETTINGS_THEME_HEADER = "Theme"
const val LIVE_COMING_SOON = "Connecting to a simulator is coming in Plan 3."
}
}
51 changes: 1 addition & 50 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<activity
android:name=".HawkeyeActivity"
android:exported="false"
android:process=":renderer"
android:launchMode="singleTop"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode|fontScale|smallestScreenSize|screenLayout|density|layoutDirection">
Expand All @@ -36,56 +37,6 @@
android:value="hawkeye" />
</activity>

<!-- Trampoline for VIEW/SEND intents. Plain ComponentActivity, no native code —
keeps NativeActivity (HawkeyeActivity) from ever being cold-launched with an
intent, which races Raylib's InitGraphicsDevice against the system's
orientation/surface setup and crashes in DrawMesh+76. -->
<activity
android:name=".IntentRouterActivity"
android:exported="true"
android:theme="@style/Theme.HawkeyeTrampoline"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode|fontScale|smallestScreenSize|screenLayout|density|layoutDirection"
android:taskAffinity=""
android:launchMode="singleInstance"
android:excludeFromRecents="true"
android:noHistory="true">

<!-- file:// VIEW with .ulg extension. Triple pathPattern is the standard
Android workaround for filenames containing dots (e.g. log.2024.ulg).
No android:host: file:///path URIs have a null authority, and any
host constraint (including host="*") would NO_MATCH them via AOSP's
AuthorityEntry.match. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.ulg" />
<data android:pathPattern=".*\\..*\\.ulg" />
<data android:pathPattern=".*\\..*\\..*\\.ulg" />
</intent-filter>

<!-- content:// VIEW + SEND. ULog has no IANA-registered MIME, and
in practice every real-world sender (WhatsApp, Gmail, Drive,
Files) tags .ulg as application/octet-stream because that's
the catch-all for "binary file we don't recognize". Without
octet-stream here Hawkeye never appears in the share sheet
for actual user-shared logs. The cost is that we also appear
for other unknown binaries — accepted; pathPattern can't
filter content URIs (provider-internal paths). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/ulog" />
<data android:mimeType="application/x-ulog" />
</intent-filter>
</activity>

</application>

</manifest>
1 change: 1 addition & 0 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ set(HAWKEYE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../src")

add_library(hawkeye SHARED
android_main.c
replay_jni.c
${HAWKEYE_SRC}/scene.c
${HAWKEYE_SRC}/vehicle.c
${HAWKEYE_SRC}/theme.c
Expand Down
22 changes: 16 additions & 6 deletions android/app/src/main/cpp/android_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "vehicle.h"
#include "data_source.h"
#include "hud.h"
#include "replay_control.h"
#include <android/asset_manager.h>
#include <android/input.h>
#include <android/keycodes.h>
Expand Down Expand Up @@ -359,10 +360,10 @@ static void handle_touch(Camera3D *cam, Vector3 *orbit_target,
}
}

// HawkeyeActivity (Kotlin) writes inbound .ulg files via .tmp + atomic rename to
// The library repository (Kotlin) stages a .ulg via .tmp + atomic rename to
// inbox/current.ulg, then writes a fresh millis token into inbox/.ready. We poll
// the sentinel's *contents* (not stat-mtime — f2fs has 1-second granularity and
// would coalesce two shares in the same wall second) once per second and reload
// would coalesce two stages in the same wall second) once per second and reload
// the replay when the token changes.
static data_source_t g_ds;
static bool g_ds_active = false;
Expand Down Expand Up @@ -391,7 +392,7 @@ static long long read_ready_token(const char *path) {
}

static int try_load_inbox_ulog(vehicle_t *vehicle) {
// Throttle to 1 Hz. Sentinel changes are user-driven (intent shares), so
// Throttle to 1 Hz. Sentinel changes are user-driven (library playback), so
// a per-frame stat()/read() at 60 Hz is wasted work — and since we read
// the sentinel's contents (not its mtime), an up-to-1-second detection
// delay is the only cost.
Expand All @@ -415,7 +416,7 @@ static int try_load_inbox_ulog(vehicle_t *vehicle) {
__android_log_print(ANDROID_LOG_ERROR, "Hawkeye",
"data_source_ulog_create(%s) failed: %d", ulg, rc);
// Mark the bad token consumed so we don't retry every second; the
// user must re-share to trigger another attempt.
// user must re-open the log to trigger another attempt.
g_last_ready_token = token;
return 0;
}
Expand Down Expand Up @@ -493,6 +494,9 @@ int main(int argc, char *argv[]) {
vehicle_init(&vehicle, MODEL_QUADROTOR, scene.lighting_shader);

hud_init(&g_hud);
// The transport bar is driven by a Compose overlay on Android, so suppress the
// native one (keeps the rest of the HUD: instruments, telemetry, annunciators).
g_hud.show_transport = false;
// Bump HUD scale on mobile so values/labels meet M3 readability floors
// (~42 px body, ~33 px label at this DPI). Desktop/WASM leave this at 1.0.
g_hud.scale_mul = 1.5f;
Expand All @@ -512,13 +516,16 @@ int main(int argc, char *argv[]) {
scene.camera.target = orbit_target;
g_last_vehicle_pos = vehicle.position;

// Pick up a .ulg already delivered by an intent before native main() ran.
// Pick up a .ulg already staged into the inbox before native main() ran.
try_load_inbox_ulog(&vehicle);

while (!WindowShouldClose()) {
// Catch re-share into the running app (HawkeyeActivity.onNewIntent).
// Catch a new log staged while the renderer is already running.
try_load_inbox_ulog(&vehicle);

// Apply pending transport requests from the Compose overlay (JVM thread).
replay_control_apply(&g_ds, g_ds_active);

if (g_ds_active) {
data_source_poll(&g_ds, GetFrameTime());
vehicle_update(&vehicle, &g_ds.state, &g_ds.home);
Expand All @@ -535,6 +542,9 @@ int main(int argc, char *argv[]) {
}
}

// Publish playback status for the Compose overlay to read via JNI.
replay_control_publish(&g_ds, g_ds_active);

handle_touch(&scene.camera, &orbit_target,
&prev_count, &prev_touch,
&prev_pinch_dist, &prev_mid);
Expand Down
Loading