feat(android): follow the activity lifecycle and build an APK (Issue #42)
- The drawing surface is created once the native window exists (Resumed) and released when the app goes to the background (Suspended); before that everything is saved, as Android may stop the app at any time - Android's back button acts like Escape instead of closing the app - Panics are written to logcat; no update check on Android yet - scripts/package-android.sh builds target/ink-notes_<version>_arm64.apk without Gradle (NDK, aapt2, zipalign, apksigner); signing key from ANDROID_KEYSTORE/ANDROID_KEYSTORE_PASS/ANDROID_KEY_ALIAS, otherwise the debug key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce3a1f697b
commit
7faf78ddeb
9 files changed
+1662
-353
No files matched your search
+1
-1
@@ -69,7 +69,7 @@ wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
||||
|
||||
# Android (Issue #42): winit bound to the native activity. The library is built as
|
||||
# cdylib with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`
|
||||
# cdylib and packed into an APK by scripts/package-android.sh
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
||||
android_logger = { version = "0.13", default-features = false }
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
|
||||
|
||||
Andere Systeme:
|
||||
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||
- **Android** (in Arbeit, Issue #42): `cargo check --target aarch64-linux-android` und `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android` mit dem NDK-Compiler in `CC_aarch64_linux_android`. Dort startet die App noch nicht: Die Fensterfläche muss erst dem Lebenszyklus der Activity folgen.
|
||||
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Notizbücher bleiben vorerst in der App; Stifterkennung, Bildschirmtastatur und Dateidialoge fehlen noch.
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D
|
||||
|
||||
Other systems:
|
||||
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||
- **Android** (in progress, Issue #42): `cargo check --target aarch64-linux-android` and `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android` with the NDK compiler in `CC_aarch64_linux_android`. The app does not start there yet: the window surface still has to follow the activity lifecycle.
|
||||
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. Notebooks stay inside the app for now; pen detection, on-screen keyboard and file dialogs are still missing.
|
||||
|
||||
New code comments and commit messages are written in English; older code and history still contain German.
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Manifest of the Android app (Issue #42). scripts/package-android.sh fills in the version.
|
||||
NativeActivity loads libink_notes.so, whose android_main runs the app. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.pansi.inknotes"
|
||||
android:versionCode="@VERSION_CODE@"
|
||||
android:versionName="@VERSION@">
|
||||
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="ink-notes"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="false"
|
||||
android:extractNativeLibs="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<!-- configChanges: rotating the tablet or attaching a keyboard must not restart the
|
||||
activity; the app adapts to the new window size itself -->
|
||||
<activity
|
||||
android:name="android.app.NativeActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|density|uiMode|fontScale|locale|layoutDirection">
|
||||
<meta-data android:name="android.app.lib_name" android:value="ink_notes" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Builds the Android app target/ink-notes_<version>_arm64.apk (Issues #42, #45)
|
||||
# without Gradle: the Rust library is built as cdylib with the NDK, aapt2 links the
|
||||
# manifest and the icon, zipalign and apksigner finish the APK.
|
||||
#
|
||||
# Needs the Android SDK (build-tools, platform android-34) and the NDK:
|
||||
# ANDROID_HOME SDK folder (default: ~/android-sdk)
|
||||
# ANDROID_NDK_HOME NDK folder (default: newest in $ANDROID_HOME/ndk)
|
||||
#
|
||||
# Signing key (all three, otherwise the debug key ~/.android/debug.keystore is used and
|
||||
# created if missing; such an APK is only meant for testing):
|
||||
# ANDROID_KEYSTORE, ANDROID_KEYSTORE_PASS, ANDROID_KEY_ALIAS
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
TARGET="aarch64-linux-android"
|
||||
ABI="arm64-v8a"
|
||||
MIN_SDK=26
|
||||
TARGET_SDK=34
|
||||
VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
|
||||
IFS=. read -r MAJOR MINOR PATCH <<<"${VERSION%%-*}"
|
||||
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
|
||||
APK_NAME="ink-notes_${VERSION}_arm64.apk"
|
||||
|
||||
ANDROID_HOME="${ANDROID_HOME:-${HOME}/android-sdk}"
|
||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||
ANDROID_NDK_HOME=$(ls -d "${ANDROID_HOME}"/ndk/*/ 2>/dev/null | sort -V | tail -n1)
|
||||
fi
|
||||
BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/*/ 2>/dev/null | sort -V | tail -n1)
|
||||
ANDROID_JAR="${ANDROID_HOME}/platforms/android-${TARGET_SDK}/android.jar"
|
||||
NDK_BIN="${ANDROID_NDK_HOME%/}/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
for needed in "${NDK_BIN}/${TARGET}${MIN_SDK}-clang" "${BUILD_TOOLS}aapt2" "${ANDROID_JAR}"; do
|
||||
if [ ! -e "${needed}" ]; then
|
||||
echo "FEHLER: ${needed} nicht gefunden (Android-SDK/NDK, siehe Kopf des Skripts)."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup target add "${TARGET}"
|
||||
fi
|
||||
|
||||
echo "=== [1/4] Baue libink_notes.so (${TARGET}) ==="
|
||||
export CC_aarch64_linux_android="${NDK_BIN}/${TARGET}${MIN_SDK}-clang"
|
||||
export AR_aarch64_linux_android="${NDK_BIN}/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${NDK_BIN}/${TARGET}${MIN_SDK}-clang"
|
||||
cargo rustc --release --lib --crate-type cdylib --target "${TARGET}"
|
||||
LIB="${CARGO_TARGET_DIR:-target}/${TARGET}/release/libink_notes.so"
|
||||
|
||||
echo "=== [2/4] Ressourcen und Manifest ==="
|
||||
WORK="target/android-apk"
|
||||
rm -rf "${WORK}"
|
||||
mkdir -p "${WORK}/res/mipmap-xxxhdpi" "${WORK}/compiled" "${WORK}/apk/lib/${ABI}"
|
||||
cp assets/ink-notes.png "${WORK}/res/mipmap-xxxhdpi/ic_launcher.png"
|
||||
sed -e "s/@VERSION_CODE@/${VERSION_CODE}/" -e "s/@VERSION@/${VERSION}/" \
|
||||
packaging/android/AndroidManifest.xml >"${WORK}/AndroidManifest.xml"
|
||||
"${BUILD_TOOLS}aapt2" compile --dir "${WORK}/res" -o "${WORK}/compiled/res.zip"
|
||||
"${BUILD_TOOLS}aapt2" link -o "${WORK}/unaligned.apk" -I "${ANDROID_JAR}" \
|
||||
--manifest "${WORK}/AndroidManifest.xml" \
|
||||
--min-sdk-version "${MIN_SDK}" --target-sdk-version "${TARGET_SDK}" \
|
||||
"${WORK}/compiled/res.zip"
|
||||
|
||||
echo "=== [3/4] Native Bibliothek hinzufügen ==="
|
||||
cp "${LIB}" "${WORK}/apk/lib/${ABI}/"
|
||||
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk lib)
|
||||
"${BUILD_TOOLS}zipalign" -f -p 4 "${WORK}/unaligned.apk" "${WORK}/aligned.apk"
|
||||
|
||||
echo "=== [4/4] Signieren ==="
|
||||
if [ -n "${ANDROID_KEYSTORE:-}" ] && [ -n "${ANDROID_KEYSTORE_PASS:-}" ] && [ -n "${ANDROID_KEY_ALIAS:-}" ]; then
|
||||
KEYSTORE="${ANDROID_KEYSTORE}"
|
||||
KEY_ALIAS="${ANDROID_KEY_ALIAS}"
|
||||
export KS_PASS="${ANDROID_KEYSTORE_PASS}"
|
||||
else
|
||||
KEYSTORE="${HOME}/.android/debug.keystore"
|
||||
KEY_ALIAS="androiddebugkey"
|
||||
export KS_PASS="android"
|
||||
if [ ! -f "${KEYSTORE}" ]; then
|
||||
mkdir -p "$(dirname "${KEYSTORE}")"
|
||||
keytool -genkeypair -keystore "${KEYSTORE}" -storepass android -keypass android \
|
||||
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=Android Debug,O=Android,C=US"
|
||||
fi
|
||||
echo "Hinweis: Signiert mit dem Debug-Schlüssel ${KEYSTORE} (nur zum Testen)."
|
||||
fi
|
||||
"${BUILD_TOOLS}apksigner" sign --ks "${KEYSTORE}" --ks-key-alias "${KEY_ALIAS}" \
|
||||
--ks-pass env:KS_PASS --key-pass env:KS_PASS \
|
||||
--out "target/${APK_NAME}" "${WORK}/aligned.apk"
|
||||
"${BUILD_TOOLS}apksigner" verify "target/${APK_NAME}"
|
||||
ls -lh "target/${APK_NAME}"
|
||||
+1443
-342
File diff suppressed because it is too large.
Load diff
+43
-5
@@ -165,7 +165,10 @@ struct NeighborPage {
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
/// Drawing surface of the window; `None` while the app is in the background on Android
|
||||
/// (the native window only exists between `Resumed` and `Suspended`)
|
||||
surface: Option<wgpu::Surface<'static>>,
|
||||
instance: wgpu::Instance,
|
||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||
adapter: wgpu::Adapter,
|
||||
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||
@@ -591,7 +594,8 @@ impl CanvasState {
|
||||
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
surface: Some(surface),
|
||||
instance,
|
||||
adapter,
|
||||
msaa_samples,
|
||||
msaa_view,
|
||||
@@ -727,14 +731,42 @@ impl CanvasState {
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Releases the drawing surface: on Android the native window goes away when the app
|
||||
/// is sent to the background (`Event::Suspended`). Drawing is skipped until [`Self::resume`].
|
||||
pub fn suspend(&mut self) {
|
||||
self.surface = None;
|
||||
}
|
||||
|
||||
/// Creates the drawing surface again for the window (`Event::Resumed` on Android).
|
||||
pub fn resume(&mut self, window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.surface.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let surface = self.instance.create_surface(window.clone())?;
|
||||
if !surface
|
||||
.get_capabilities(&self.adapter)
|
||||
.formats
|
||||
.contains(&self.config.format)
|
||||
{
|
||||
return Err("surface format changed".into());
|
||||
}
|
||||
self.surface = Some(surface);
|
||||
// Configures the new surface (the window may have changed size meanwhile)
|
||||
self.resize(window.inner_size());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||
if new_size.width > 0 && new_size.height > 0 {
|
||||
self.size = new_size;
|
||||
self.config.width = new_size.width;
|
||||
self.config.height = new_size.height;
|
||||
let Some(surface) = &self.surface else {
|
||||
return;
|
||||
};
|
||||
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
||||
let caps = self.surface.get_capabilities(&self.adapter);
|
||||
let caps = surface.get_capabilities(&self.adapter);
|
||||
if !caps.present_modes.contains(&self.config.present_mode) {
|
||||
match choose_present_mode(&caps.present_modes) {
|
||||
Some(mode) => self.config.present_mode = mode,
|
||||
@@ -750,7 +782,7 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
surface.configure(&self.device, &self.config);
|
||||
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.recalculate_viewport();
|
||||
@@ -1386,7 +1418,13 @@ impl CanvasState {
|
||||
.update_texture(&self.device, &self.queue, *id, image_delta);
|
||||
}
|
||||
|
||||
let output = self.surface.get_current_texture()?;
|
||||
let Some(surface) = &self.surface else {
|
||||
for id in &textures_delta.free {
|
||||
self.egui_renderer.free_texture(id);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let output = surface.get_current_texture()?;
|
||||
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(render_format(&self.config)),
|
||||
..Default::default()
|
||||
|
||||
+3
-3
@@ -38,9 +38,7 @@ pub mod xopp;
|
||||
|
||||
/// Entry point on Android (Issue #42), called by the native activity.
|
||||
///
|
||||
/// Builds with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`. Not
|
||||
/// usable yet: the window surface still has to be created on `Resumed` and dropped on
|
||||
/// `Suspended`.
|
||||
/// `scripts/package-android.sh` builds the library and packs it into an APK.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||
@@ -51,6 +49,8 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("ink-notes"),
|
||||
);
|
||||
// Panics would otherwise only go to stderr, which Android discards
|
||||
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
|
||||
if let Some(dir) = app.internal_data_path() {
|
||||
paths::set_app_dir(dir);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,51 @@ pub fn desktop_event_loop() -> Result<winit::event_loop::EventLoop<()>, winit::e
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Waits until the window can be drawn to. On Android the native window only exists after
|
||||
/// `Event::Resumed` (Issue #42); on the desktop it is there right away.
|
||||
pub fn wait_until_resumed(
|
||||
#[allow(unused_variables)] event_loop: &mut winit::event_loop::EventLoop<()>,
|
||||
) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use winit::event::Event;
|
||||
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
|
||||
let mut resumed = false;
|
||||
while !resumed {
|
||||
let status = event_loop.pump_events(None, |event, _| {
|
||||
if matches!(event, Event::Resumed) {
|
||||
resumed = true;
|
||||
}
|
||||
});
|
||||
if let PumpStatus::Exit(_) = status {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the event loop until the app quits. On Android the loop was already started by
|
||||
/// [`wait_until_resumed`], so it is driven with `pump_events` (`run` would refuse to start).
|
||||
pub fn run_event_loop<F>(
|
||||
#[allow(unused_mut)] mut event_loop: winit::event_loop::EventLoop<()>,
|
||||
#[allow(unused_mut)] mut handler: F,
|
||||
) -> Result<(), winit::error::EventLoopError>
|
||||
where
|
||||
F: FnMut(winit::event::Event<()>, &winit::event_loop::EventLoopWindowTarget<()>),
|
||||
{
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
|
||||
loop {
|
||||
if let PumpStatus::Exit(_) = event_loop.pump_events(None, &mut handler) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
event_loop.run(handler)
|
||||
}
|
||||
|
||||
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
|
||||
/// show the app icon for it.
|
||||
pub fn with_app_id(
|
||||
|
||||
Reference in new issue
Block a user