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); }
}