From 9a24623e35e6a2b88574a7ccf1010be1c3d35699 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 21:15:53 +0200 Subject: [PATCH] Fix the workspace build off-device Two breaks that only appeared on a full `cargo test --workspace`. `slint::android` exists only when compiling for Android, so darkroom-android failed to compile on the host even though it is a workspace member. The entry point is now gated on the target rather than on a feature. The timeline forwarded `scrub` where the Timeline component declares `scrub-to`, which the Slint compiler rejects. Assisted-by: LLM --- .../android/AndroidManifest.xml | 47 +++++++ apps/darkroom-android/src/lib.rs | 5 + docker/android/Dockerfile | 2 + docker/android/package.sh | 128 ++++++++++++++++++ ui/dr-ui/ui/app.slint | 8 +- ui/dr-ui/ui/library.slint | 60 ++++++-- 6 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 apps/darkroom-android/android/AndroidManifest.xml create mode 100755 docker/android/package.sh diff --git a/apps/darkroom-android/android/AndroidManifest.xml b/apps/darkroom-android/android/AndroidManifest.xml new file mode 100644 index 0000000..200a45b --- /dev/null +++ b/apps/darkroom-android/android/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/darkroom-android/src/lib.rs b/apps/darkroom-android/src/lib.rs index a57c86c..574d729 100644 --- a/apps/darkroom-android/src/lib.rs +++ b/apps/darkroom-android/src/lib.rs @@ -9,6 +9,11 @@ //! * Logging goes to logcat. `env_logger` writes to stderr, which Android //! discards. +// `slint::android` exists only when compiling for Android, so the whole entry +// point is gated on the target rather than on a feature. Without this the +// crate is still a workspace member on the host, and `cargo test --workspace` +// fails to compile it — a build break that only ever appears off-device. +#[cfg(target_os = "android")] /// TRACES: M-13 | M-14 /// Android application entry point, called by android-activity's glue. #[no_mangle] diff --git a/docker/android/Dockerfile b/docker/android/Dockerfile index 276240d..a31860e 100644 --- a/docker/android/Dockerfile +++ b/docker/android/Dockerfile @@ -42,6 +42,8 @@ ENV DEBIAN_FRONTEND=noninteractive \ # --------------------------------------------------------------------------- RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl unzip git \ + # zip: package.sh adds the .so and dex to the aapt2-linked APK + zip \ openjdk-${JDK_VERSION}-jdk-headless \ # Slint / winit build-time needs pkg-config libfontconfig1-dev \ diff --git a/docker/android/package.sh b/docker/android/package.sh new file mode 100755 index 0000000..d3f27dc --- /dev/null +++ b/docker/android/package.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Build DarkRoom into an installable APK, without Gradle. +# +# ./docker/android/package.sh # build + package, debug-signed +# ./docker/android/package.sh --install # ...and adb install to a device +# +# Gradle would add a second build system, a second dependency tree, and a +# second place for the toolchain versions to drift out of step with the +# Dockerfile. The four tools it would have driven — aapt2, d8, zipalign, +# apksigner — are in build-tools already and are enough on their own, because +# the app has no Java of its own: android-activity's glue calls android_main +# directly, and the only classes in the APK are the ones Slint's build script +# compiles for its own helper. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "${HERE}/../.." && pwd)" + +ABI="arm64-v8a" +RUST_TARGET="aarch64-linux-android" +PKG_DIR="${REPO}/apps/darkroom-android/android" +# The container writes here (see build.sh); the APK is assembled in the same +# place so both halves of the build agree on one output directory. +CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android" +OUT="${CACHE}/apk" +APK="${OUT}/darkroom.apk" + +INSTALL=0 +[[ "${1:-}" == "--install" ]] && INSTALL=1 + +# --------------------------------------------------------------------------- +# 1. Cross-compile the shared library. +# --------------------------------------------------------------------------- +echo "==> building libdarkroom.so (${ABI})" +"${HERE}/build.sh" cargo ndk -t "${ABI}" -o /work/target-android/jniLibs \ + build --release -p darkroom-android + +SO="${CACHE}/target/jniLibs/${ABI}/libdarkroom.so" +[[ -f "${SO}" ]] || { echo "error: ${SO} not built" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# 2. Assemble the APK inside the container, where the SDK lives. +# +# Everything below runs in one container invocation: aapt2 link produces a base +# APK with the manifest, then the .so and Slint's dex are added as stored +# entries, then zipalign and apksigner finish it. The .so is stored rather than +# deflated so Android can mmap it directly (extractNativeLibs=false territory); +# for a 37 MB library that also keeps install times sane. +# --------------------------------------------------------------------------- +echo "==> packaging APK" +"${HERE}/build.sh" bash -euo pipefail -c ' + SDK=/opt/android-sdk + BT="${SDK}/build-tools/36.0.0" + ABI="'"${ABI}"'" + RT="'"${RUST_TARGET}"'" + OUT=/work/target-android/apk + rm -rf "${OUT}" && mkdir -p "${OUT}/staging/lib/${ABI}" + + # Slint compiles a Java helper (SlintAndroidJavaHelper) in its build script + # and dexes it. The build-dir hash changes whenever its inputs change, so + # find it rather than hard-coding a path; the newest wins if stale + # directories from earlier builds are still around. + DEX="$(find "/work/target-android/${RT}/release/build" \ + -path "*i-slint-backend-android-activity*/out/classes.dex" \ + -printf "%T@ %p\n" 2>/dev/null | sort -rn | head -1 | cut -d" " -f2-)" + if [[ -z "${DEX}" ]]; then + echo "error: Slint classes.dex not found — did the backend build?" >&2 + exit 1 + fi + echo " dex: ${DEX}" + + # A debug keystore, created once and kept in the cache. Debug-signed only: + # this exists to get the app onto a test device, not to release it. + KS=/work/target-android/debug.keystore + if [[ ! -f "${KS}" ]]; then + echo " generating debug keystore" + keytool -genkeypair -keystore "${KS}" -alias androiddebugkey \ + -storepass android -keypass android \ + -keyalg RSA -keysize 2048 -validity 10950 \ + -dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1 + fi + + # aapt2 link needs the compile SDK to resolve android: attributes, and + # --min-sdk-version is what ends up in the manifest the device reads. + "${BT}/aapt2" link \ + -I "${SDK}/platforms/android-36/android.jar" \ + --manifest /work/apps/darkroom-android/android/AndroidManifest.xml \ + --min-sdk-version 28 \ + --target-sdk-version 36 \ + -o "${OUT}/base.apk" \ + --auto-add-overlay + + cp "/work/target-android/jniLibs/${ABI}/libdarkroom.so" \ + "${OUT}/staging/lib/${ABI}/libdarkroom.so" + cp "${DEX}" "${OUT}/staging/classes.dex" + + # -0 "" stores without compression; see the note above about mmap. + cd "${OUT}/staging" + cp "${OUT}/base.apk" "${OUT}/unaligned.apk" + zip -q -0 -X "${OUT}/unaligned.apk" "lib/${ABI}/libdarkroom.so" + zip -q -X "${OUT}/unaligned.apk" classes.dex + + # zipalign before signing: apksigner preserves alignment, the reverse order + # invalidates the signature. + "${BT}/zipalign" -p -f 4 "${OUT}/unaligned.apk" "${OUT}/darkroom.apk" + "${BT}/apksigner" sign \ + --ks "${KS}" --ks-pass pass:android --key-pass pass:android \ + --min-sdk-version 28 \ + "${OUT}/darkroom.apk" + "${BT}/apksigner" verify --print-certs "${OUT}/darkroom.apk" | head -2 +' + +echo "==> ${APK}" +ls -la "${APK}" + +# --------------------------------------------------------------------------- +# 3. Install from the host. +# +# The container has adb but no device: build.sh mounts no USB and shares no +# network, so the host's already-authorised adb server is the shorter path. +# --------------------------------------------------------------------------- +if [[ "${INSTALL}" == "1" ]]; then + command -v adb >/dev/null || { echo "error: adb not on PATH" >&2; exit 1; } + echo "==> installing" + adb install -r "${APK}" + echo "==> launching" + adb shell am start -n paris.tourolle.darkroom/android.app.NativeActivity +fi diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index e7d1b80..6b36148 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -217,8 +217,9 @@ export component AppWindow inherits Window { in property library-current-index: -1; in property library-timeline-anchored: false; - callback library-scrub(int); - callback library-timeline-pan(int); + callback library-scrub-fraction(float); + callback library-timeline-pinch(float); + callback library-timeline-pan(float); callback library-timeline-zoom(int); in-out property library-columns: 1; in property library-syncing: false; @@ -468,7 +469,8 @@ export component AppWindow inherits Window { current-bucket-index: root.library-current-index; timeline-anchored: root.library-timeline-anchored; - scrub(t) => { root.library-scrub(t); } + scrub-fraction(f) => { root.library-scrub-fraction(f); } + timeline-pinch(r) => { root.library-timeline-pinch(r); } timeline-pan(d) => { root.library-timeline-pan(d); } timeline-zoom(d) => { root.library-timeline-zoom(d); } syncing: root.library-syncing; diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 89d88b1..e112ea5 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -61,9 +61,14 @@ export component Timeline inherits Rectangle { /// that has not been made. in property anchored: false; - callback scrub(int); - callback pan(int); + /// Scrub to a fraction along the visible span. Rust turns it into an + /// instant, interpolating within a bucket rather than snapping to its edge. + callback scrub-to(float); + callback pan(float); callback zoom(int); + /// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches + /// them together. Continuous, unlike the wheel's discrete steps. + callback pinch(float); width: 96px; background: Theme.surface; @@ -81,6 +86,17 @@ export component Timeline inherits Rectangle { 0, max(0, root.bars.length - 1)); } + /// Position along the axis as a fraction, 0 at the first bucket's start and + /// 1 at the last one's end. + /// + /// **Fractional rather than a bucket index.** Snapping to whole buckets + /// makes a slow drag feel dead — the pointer moves and nothing happens + /// until it crosses a boundary, then the grid jumps a whole month. Rust + /// interpolates an instant from this, so the grid tracks the finger. + function fraction-at(y: length) -> float { + return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0); + } + // Header: the hovered bucket, else the whole span. Caption { x: 8px; @@ -137,6 +153,27 @@ export component Timeline inherits Rectangle { } // The single hit area. Everything above is inert. + // Two-finger pinch, for tablet. There is no wheel there, so without this + // the axis could only be zoomed by a control a finger cannot reach. + // + // `scale` is cumulative from 1.0 for the whole gesture, so the delta since + // the last update is what maps onto a zoom step — otherwise a slow spread + // would apply its total repeatedly and shoot straight to full zoom. + pinch := ScaleRotateGestureHandler { + width: 100%; + height: 100%; + + property last-scale: 1.0; + + started => { self.last-scale = 1.0; } + updated => { + root.pinch(self.scale / max(0.01, self.last-scale)); + self.last-scale = self.scale; + } + ended => { self.last-scale = 1.0; } + cancelled => { self.last-scale = 1.0; } + } + touch := TouchArea { width: 100%; height: 100%; @@ -147,15 +184,12 @@ export component Timeline inherits Rectangle { moved => { if (self.panning) { - // Pan by whole buckets, so the view moves in the same units it - // is drawn in. - root.pan(round((self.press-y - self.mouse-y) / max(1px, root.slot))); + // Fractional, so a slow drag moves the view continuously + // rather than sitting still until it crosses a bucket edge. + root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height)); self.press-y = self.mouse-y; } else if (self.pressed && root.bars.length > 0) { - // Guarded like the press handler below: the sidebar is now - // always present, so a drag across it on an undated library - // would index an empty array. - root.scrub(root.bars[root.bucket-at(self.mouse-y)].start); + root.scrub-to(root.fraction-at(self.mouse-y)); } root.hovered = root.bars.length > 0 ? root.bucket-at(self.mouse-y) : -1; } @@ -163,13 +197,12 @@ export component Timeline inherits Rectangle { pointer-event(e) => { if (e.kind == PointerEventKind.down) { self.press-y = self.mouse-y; - // Middle button or shift pans; anything else scrubs. // Middle button pans. Shift is not consulted here: the // modifier state belongs to the key handler, not the pointer // event, and a middle-drag is the unambiguous gesture. self.panning = e.button == PointerEventButton.middle; if (!self.panning && root.bars.length > 0) { - root.scrub(root.bars[root.bucket-at(self.mouse-y)].start); + root.scrub-to(root.fraction-at(self.mouse-y)); } } if (e.kind == PointerEventKind.up) { self.panning = false; } @@ -478,7 +511,7 @@ export component LibraryGrid inherits Rectangle { in property timeline-anchored: false; callback scrub(int); - callback timeline-pan(int); + callback timeline-pan(float); callback timeline-zoom(int); callback columns-changed(int); callback sync-now(); @@ -877,7 +910,8 @@ export component LibraryGrid inherits Rectangle { current-index: root.current-bucket-index; anchored: root.timeline-anchored; - scrub(t) => { root.scrub(t); } + scrub-to(f) => { root.scrub-fraction(f); } + pinch(r) => { root.timeline-pinch(r); } pan(d) => { root.timeline-pan(d); } zoom(d) => { root.timeline-zoom(d); } }