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
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
DarkRoom Android manifest.
|
||||||
|
|
||||||
|
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||||
|
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||||
|
here is a distribution manifest yet — no permissions are declared because
|
||||||
|
the library grid reads through SAF, which grants per-tree rather than by
|
||||||
|
manifest permission (ARCH §6.9).
|
||||||
|
-->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="paris.tourolle.darkroom">
|
||||||
|
|
||||||
|
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||||
|
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||||
|
fails at install rather than at first frame. -->
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.vulkan.version"
|
||||||
|
android:version="0x00401000"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="DarkRoom"
|
||||||
|
android:hasCode="true"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:supportsRtl="true">
|
||||||
|
|
||||||
|
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||||
|
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
|
||||||
|
is how it learns which library to load, and must match [lib].name. -->
|
||||||
|
<activity
|
||||||
|
android:name="android.app.NativeActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.lib_name"
|
||||||
|
android:value="darkroom" />
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
|
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
|
||||||
//! discards.
|
//! 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
|
/// TRACES: M-13 | M-14
|
||||||
/// Android application entry point, called by android-activity's glue.
|
/// Android application entry point, called by android-activity's glue.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl unzip git \
|
ca-certificates curl unzip git \
|
||||||
|
# zip: package.sh adds the .so and dex to the aapt2-linked APK
|
||||||
|
zip \
|
||||||
openjdk-${JDK_VERSION}-jdk-headless \
|
openjdk-${JDK_VERSION}-jdk-headless \
|
||||||
# Slint / winit build-time needs
|
# Slint / winit build-time needs
|
||||||
pkg-config libfontconfig1-dev \
|
pkg-config libfontconfig1-dev \
|
||||||
|
|||||||
Executable
+128
@@ -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
|
||||||
@@ -217,8 +217,9 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> library-current-index: -1;
|
in property <int> library-current-index: -1;
|
||||||
in property <bool> library-timeline-anchored: false;
|
in property <bool> library-timeline-anchored: false;
|
||||||
|
|
||||||
callback library-scrub(int);
|
callback library-scrub-fraction(float);
|
||||||
callback library-timeline-pan(int);
|
callback library-timeline-pinch(float);
|
||||||
|
callback library-timeline-pan(float);
|
||||||
callback library-timeline-zoom(int);
|
callback library-timeline-zoom(int);
|
||||||
in-out property <int> library-columns: 1;
|
in-out property <int> library-columns: 1;
|
||||||
in property <bool> library-syncing: false;
|
in property <bool> library-syncing: false;
|
||||||
@@ -468,7 +469,8 @@ export component AppWindow inherits Window {
|
|||||||
current-bucket-index: root.library-current-index;
|
current-bucket-index: root.library-current-index;
|
||||||
timeline-anchored: root.library-timeline-anchored;
|
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-pan(d) => { root.library-timeline-pan(d); }
|
||||||
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
||||||
syncing: root.library-syncing;
|
syncing: root.library-syncing;
|
||||||
|
|||||||
+47
-13
@@ -61,9 +61,14 @@ export component Timeline inherits Rectangle {
|
|||||||
/// that has not been made.
|
/// that has not been made.
|
||||||
in property <bool> anchored: false;
|
in property <bool> anchored: false;
|
||||||
|
|
||||||
callback scrub(int);
|
/// Scrub to a fraction along the visible span. Rust turns it into an
|
||||||
callback pan(int);
|
/// instant, interpolating within a bucket rather than snapping to its edge.
|
||||||
|
callback scrub-to(float);
|
||||||
|
callback pan(float);
|
||||||
callback zoom(int);
|
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;
|
width: 96px;
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
@@ -81,6 +86,17 @@ export component Timeline inherits Rectangle {
|
|||||||
0, max(0, root.bars.length - 1));
|
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.
|
// Header: the hovered bucket, else the whole span.
|
||||||
Caption {
|
Caption {
|
||||||
x: 8px;
|
x: 8px;
|
||||||
@@ -137,6 +153,27 @@ export component Timeline inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The single hit area. Everything above is inert.
|
// 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 <float> 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 {
|
touch := TouchArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -147,15 +184,12 @@ export component Timeline inherits Rectangle {
|
|||||||
|
|
||||||
moved => {
|
moved => {
|
||||||
if (self.panning) {
|
if (self.panning) {
|
||||||
// Pan by whole buckets, so the view moves in the same units it
|
// Fractional, so a slow drag moves the view continuously
|
||||||
// is drawn in.
|
// rather than sitting still until it crosses a bucket edge.
|
||||||
root.pan(round((self.press-y - self.mouse-y) / max(1px, root.slot)));
|
root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height));
|
||||||
self.press-y = self.mouse-y;
|
self.press-y = self.mouse-y;
|
||||||
} else if (self.pressed && root.bars.length > 0) {
|
} else if (self.pressed && root.bars.length > 0) {
|
||||||
// Guarded like the press handler below: the sidebar is now
|
root.scrub-to(root.fraction-at(self.mouse-y));
|
||||||
// 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.hovered = root.bars.length > 0 ? root.bucket-at(self.mouse-y) : -1;
|
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) => {
|
pointer-event(e) => {
|
||||||
if (e.kind == PointerEventKind.down) {
|
if (e.kind == PointerEventKind.down) {
|
||||||
self.press-y = self.mouse-y;
|
self.press-y = self.mouse-y;
|
||||||
// Middle button or shift pans; anything else scrubs.
|
|
||||||
// Middle button pans. Shift is not consulted here: the
|
// Middle button pans. Shift is not consulted here: the
|
||||||
// modifier state belongs to the key handler, not the pointer
|
// modifier state belongs to the key handler, not the pointer
|
||||||
// event, and a middle-drag is the unambiguous gesture.
|
// event, and a middle-drag is the unambiguous gesture.
|
||||||
self.panning = e.button == PointerEventButton.middle;
|
self.panning = e.button == PointerEventButton.middle;
|
||||||
if (!self.panning && root.bars.length > 0) {
|
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; }
|
if (e.kind == PointerEventKind.up) { self.panning = false; }
|
||||||
@@ -478,7 +511,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
in property <bool> timeline-anchored: false;
|
in property <bool> timeline-anchored: false;
|
||||||
|
|
||||||
callback scrub(int);
|
callback scrub(int);
|
||||||
callback timeline-pan(int);
|
callback timeline-pan(float);
|
||||||
callback timeline-zoom(int);
|
callback timeline-zoom(int);
|
||||||
callback columns-changed(int);
|
callback columns-changed(int);
|
||||||
callback sync-now();
|
callback sync-now();
|
||||||
@@ -877,7 +910,8 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
current-index: root.current-bucket-index;
|
current-index: root.current-bucket-index;
|
||||||
anchored: root.timeline-anchored;
|
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); }
|
pan(d) => { root.timeline-pan(d); }
|
||||||
zoom(d) => { root.timeline-zoom(d); }
|
zoom(d) => { root.timeline-zoom(d); }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user