Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c436fb28c | ||
|
|
7584827c04 | ||
|
|
0be321837c | ||
|
|
a8aef30db7 |
No files matched your search
@@ -79,3 +79,60 @@ jobs:
|
||||
GITEA_SERVER: ${{ github.server_url }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
android:
|
||||
# Experimental (Issue #45): builds the APK and keeps it as a workflow artifact. It is not
|
||||
# part of the release yet, and a failure here does not stop the release above.
|
||||
name: Android APK (experimental)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Install JDK and Tools
|
||||
run: |
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
$SUDO apt-get update -y
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
openjdk-17-jdk-headless build-essential pkg-config curl unzip zip
|
||||
|
||||
- name: Setup Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
toolchain: stable
|
||||
target: aarch64-linux-android
|
||||
cache: true
|
||||
rustflags: ""
|
||||
|
||||
- name: Cache Android SDK
|
||||
uses: actions/cache@v4.2.3
|
||||
with:
|
||||
path: ~/android-sdk
|
||||
key: android-sdk-${{ hashFiles('scripts/setup-android-sdk.sh') }}
|
||||
|
||||
- name: Install Android SDK and NDK
|
||||
run: ./scripts/setup-android-sdk.sh
|
||||
|
||||
- name: Build APK
|
||||
run: |
|
||||
if [ -n "${ANDROID_KEYSTORE_B64}" ]; then
|
||||
KEYSTORE="${RUNNER_TEMP:-/tmp}/release.keystore"
|
||||
printf '%s' "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE}"
|
||||
export ANDROID_KEYSTORE="${KEYSTORE}"
|
||||
fi
|
||||
./scripts/package-android.sh
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
|
||||
- name: Keep APK as Artifact
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: ink-notes-android-apk
|
||||
path: target/ink-notes_*_arm64.apk
|
||||
Generated
+1
@@ -1556,6 +1556,7 @@ dependencies = [
|
||||
"hayro",
|
||||
"hayro-svg",
|
||||
"image",
|
||||
"jni",
|
||||
"log",
|
||||
"lopdf",
|
||||
"minisign-verify",
|
||||
|
||||
@@ -73,6 +73,8 @@ wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
||||
android_logger = { version = "0.13", default-features = false }
|
||||
# Saving exports to Downloads through Android's MediaStore
|
||||
jni = { version = "0.21", default-features = false }
|
||||
log = "0.4"
|
||||
|
||||
[package.metadata.android]
|
||||
|
||||
+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** (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.
|
||||
- **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. Ohne SDK auf dem Rechner baut `scripts/build-apk-container.sh` sie in einem Container (podman oder docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installiert die festen SDK-/NDK-Versionen. Die CI baut die APK als Workflow-Artefakt (noch nicht im Release); ein Release-Schlüssel gehört in die Secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur, Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Tastaturtext und Dateidialog kommen von der kleinen Java-Activity `packaging/android/java/…/InkActivity.java` (zum Bauen ist ein JDK nötig).
|
||||
|
||||
### 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** (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.
|
||||
- **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. Without SDK on the host, `scripts/build-apk-container.sh` builds it in a container (podman or docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installs the fixed SDK/NDK versions. The CI builds the APK as a workflow artifact (not yet part of the release); a release key goes into the secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). The small Java activity `packaging/android/java/…/InkActivity.java` (needs a JDK to build) provides keyboard text and the file dialog.
|
||||
|
||||
New code comments and commit messages are written in English; older code and history still contain German.
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Highlight Card Gradient -->
|
||||
<linearGradient id="highlightGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#132644"/>
|
||||
<stop offset="100%" stop-color="#0d1b32"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Icon Hintergrund-Verlauf -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e2638"/>
|
||||
@@ -121,58 +127,98 @@
|
||||
|
||||
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||
<g transform="translate(360, 0)">
|
||||
<!-- Version & Platform Pill -->
|
||||
<g transform="translate(0, 68)">
|
||||
<rect width="214" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
||||
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
||||
LINUX • RUST & WGPU
|
||||
<!-- Top Badges -->
|
||||
<g transform="translate(0, 56)">
|
||||
<!-- Platform Pill (No version number) -->
|
||||
<rect width="248" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<circle cx="15" cy="14" r="4" fill="#22c55e"/>
|
||||
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="12" font-weight="600" fill="#cbd5e1" letter-spacing="0.6">
|
||||
LINUX • WINDOWS • ANDROID
|
||||
</text>
|
||||
|
||||
<!-- Engine Pill -->
|
||||
<g transform="translate(260, 0)">
|
||||
<rect width="138" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<text x="13" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="600" fill="#94a3b8" letter-spacing="0.5">
|
||||
⚡ RUST & WGPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Major Update Pill -->
|
||||
<g transform="translate(410, 0)">
|
||||
<rect width="144" height="28" rx="14" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||
<circle cx="15" cy="14" r="3.5" fill="#38bdf8"/>
|
||||
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="700" fill="#38bdf8" letter-spacing="0.5">
|
||||
MAJOR UPDATE
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Haupttitel: ink-notes -->
|
||||
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
||||
<text x="0" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="58" letter-spacing="-1">
|
||||
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Subtitle / Tagline -->
|
||||
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
||||
<text x="0" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="20" fill="#94a3b8">
|
||||
Notebook & PDF annotation app for pen, touch and mouse
|
||||
</text>
|
||||
|
||||
<!-- Feature Pills / Highlights -->
|
||||
<g transform="translate(0, 248)">
|
||||
<!-- Pill 1: Niedrige Latenz -->
|
||||
<!-- Feature Highlights: Row 1 (Brand New Major Changes) -->
|
||||
<g transform="translate(0, 214)">
|
||||
<!-- Pill 1: Windows & Android Support -->
|
||||
<g transform="translate(0, 0)">
|
||||
<rect width="152" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
⚡ Fast wgpu inking
|
||||
<rect width="250" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||
✨ Windows & Android Support
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 2: Stylus mit Druck -->
|
||||
<g transform="translate(164, 0)">
|
||||
<rect width="214" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🖊️ Pressure & palm rejection
|
||||
<!-- Pill 2: New Import Functions -->
|
||||
<g transform="translate(262, 0)">
|
||||
<rect width="348" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||
📥 Import: Xournal++, Rnote, Saber & PDF
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 3: Formerkennung -->
|
||||
<g transform="translate(390, 0)">
|
||||
<rect width="216" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
📐 Shape recognition & ruler
|
||||
<!-- Pill 3: Fast Inking -->
|
||||
<g transform="translate(622, 0)">
|
||||
<rect width="160" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
⚡ Fast wgpu Inking
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Feature Highlights: Row 2 (Core Strengths) -->
|
||||
<g transform="translate(0, 262)">
|
||||
<!-- Pill 4: Pressure & Palm Rejection with Vector Stylus Icon -->
|
||||
<g transform="translate(0, 0)">
|
||||
<rect width="248" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<svg x="14" y="9" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
|
||||
</svg>
|
||||
<text x="36" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
Pressure & Palm Rejection
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 4: 100% lokal -->
|
||||
<g transform="translate(618, 0)">
|
||||
<rect width="188" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🔒 100% local & atomic
|
||||
<!-- Pill 5: Shape Recognition & Ruler -->
|
||||
<g transform="translate(260, 0)">
|
||||
<rect width="254" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
📐 Shape Recognition & Ruler
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 6: 100% Local & Atomic -->
|
||||
<g transform="translate(526, 0)">
|
||||
<rect width="256" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
🔒 100% Local, Atomic & Private
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 11 KiB |
+75
-29
@@ -19,6 +19,12 @@
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Highlight Card Gradient -->
|
||||
<linearGradient id="highlightGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#132644"/>
|
||||
<stop offset="100%" stop-color="#0d1b32"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Icon Hintergrund-Verlauf -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e2638"/>
|
||||
@@ -121,58 +127,98 @@
|
||||
|
||||
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||
<g transform="translate(360, 0)">
|
||||
<!-- Version & Platform Pill -->
|
||||
<g transform="translate(0, 68)">
|
||||
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
||||
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
||||
v0.14.0 • LINUX • RUST & WGPU
|
||||
<!-- Top Badges -->
|
||||
<g transform="translate(0, 56)">
|
||||
<!-- Platform Pill (ohne Versionsnummer) -->
|
||||
<rect width="248" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<circle cx="15" cy="14" r="4" fill="#22c55e"/>
|
||||
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="12" font-weight="600" fill="#cbd5e1" letter-spacing="0.6">
|
||||
LINUX • WINDOWS • ANDROID
|
||||
</text>
|
||||
|
||||
<!-- Engine Pill -->
|
||||
<g transform="translate(260, 0)">
|
||||
<rect width="138" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<text x="13" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="600" fill="#94a3b8" letter-spacing="0.5">
|
||||
⚡ RUST & WGPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Major Update Pill -->
|
||||
<g transform="translate(410, 0)">
|
||||
<rect width="164" height="28" rx="14" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||
<circle cx="15" cy="14" r="3.5" fill="#38bdf8"/>
|
||||
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="700" fill="#38bdf8" letter-spacing="0.5">
|
||||
GROSSES UPDATE
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Haupttitel: ink-notes -->
|
||||
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
||||
<text x="0" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="58" letter-spacing="-1">
|
||||
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Subtitle / Tagline -->
|
||||
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
||||
<text x="0" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="20" fill="#94a3b8">
|
||||
Notizbuch- & PDF-Annotations-App für Stift, Touch und Maus
|
||||
</text>
|
||||
|
||||
<!-- Feature Pills / Highlights -->
|
||||
<g transform="translate(0, 248)">
|
||||
<!-- Pill 1: Niedrige Latenz -->
|
||||
<!-- Feature Highlights: Row 1 (Brandneue Hauptfeatures) -->
|
||||
<g transform="translate(0, 214)">
|
||||
<!-- Pill 1: Windows & Android Support -->
|
||||
<g transform="translate(0, 0)">
|
||||
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
⚡ Schnelles wgpu-Inking
|
||||
<rect width="254" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||
✨ Windows- & Android-Support
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 2: Stylus mit Druck -->
|
||||
<g transform="translate(198, 0)">
|
||||
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🖊️ Druck & Palm Rejection
|
||||
<!-- Pill 2: Neue Import-Funktionen -->
|
||||
<g transform="translate(266, 0)">
|
||||
<rect width="348" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||
📥 Import: Xournal++, Rnote, Saber & PDF
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 3: Formerkennung -->
|
||||
<g transform="translate(400, 0)">
|
||||
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
<!-- Pill 3: Schnelles wgpu-Inking -->
|
||||
<g transform="translate(626, 0)">
|
||||
<rect width="156" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
⚡ wgpu-Inking
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Feature Highlights: Row 2 (Kernfunktionen) -->
|
||||
<g transform="translate(0, 262)">
|
||||
<!-- Pill 4: Stiftdruck & Palm Rejection with Vector Stylus Icon -->
|
||||
<g transform="translate(0, 0)">
|
||||
<rect width="248" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<svg x="14" y="9" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
|
||||
</svg>
|
||||
<text x="36" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
Druck & Palm Rejection
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 5: Formenerkennung & Lineal -->
|
||||
<g transform="translate(260, 0)">
|
||||
<rect width="254" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
📐 Formenerkennung & Lineal
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 4: 100% lokal -->
|
||||
<g transform="translate(594, 0)">
|
||||
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🔒 100% lokal & atomar
|
||||
<!-- Pill 6: 100% lokal, atomar & privat -->
|
||||
<g transform="translate(526, 0)">
|
||||
<rect width="256" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||
🔒 100% lokal, atomar & privat
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,6 +1,7 @@
|
||||
<?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. -->
|
||||
InkActivity (packaging/android/java) extends NativeActivity, which loads libink_notes.so;
|
||||
its android_main runs the app. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.pansi.inknotes"
|
||||
android:versionCode="@VERSION_CODE@"
|
||||
@@ -11,13 +12,13 @@
|
||||
<application
|
||||
android:label="ink-notes"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="false"
|
||||
android:hasCode="true"
|
||||
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:name="eu.pansi.inknotes.InkActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|density|uiMode|fontScale|locale|layoutDirection">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Build environment for the Android APK (Issue #45): fixed versions of the JDK, Android SDK,
|
||||
# NDK and Rust, so that the APK can be built the same way anywhere.
|
||||
# Used by scripts/build-apk-container.sh (podman or docker).
|
||||
FROM docker.io/library/ubuntu:24.04
|
||||
|
||||
ARG RUST_VERSION=1.99.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl unzip zip openjdk-17-jdk-headless build-essential pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ANDROID_HOME=/opt/android-sdk \
|
||||
RUSTUP_HOME=/opt/rustup \
|
||||
CARGO_HOME=/opt/cargo \
|
||||
PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
COPY scripts/setup-android-sdk.sh /tmp/setup-android-sdk.sh
|
||||
RUN /tmp/setup-android-sdk.sh && rm /tmp/setup-android-sdk.sh
|
||||
|
||||
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal \
|
||||
--default-toolchain "${RUST_VERSION}" --target aarch64-linux-android \
|
||||
&& chmod -R a+rwX /opt/cargo /opt/rustup
|
||||
|
||||
WORKDIR /src
|
||||
@@ -0,0 +1,209 @@
|
||||
package eu.pansi.inknotes;
|
||||
|
||||
import android.app.NativeActivity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.InputType;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.BaseInputConnection;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* The app's activity (Issue #42): NativeActivity runs the Rust code (android_main in
|
||||
* libink_notes.so); this class adds what NativeActivity lacks.
|
||||
*
|
||||
* - Text input: NativeActivity's view offers keyboards no input connection, so they can only
|
||||
* send key events, and characters without a key (umlauts chosen by long press, ...) got lost.
|
||||
* An invisible view with its own input connection passes committed text to Rust.
|
||||
* - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to
|
||||
* the cache folder, as Rust can only open files by path.
|
||||
*
|
||||
* Rust calls showKeyboard and pickFile through JNI and receives the native* calls
|
||||
* (src/android_bridge.rs).
|
||||
*/
|
||||
public class InkActivity extends NativeActivity {
|
||||
static {
|
||||
// NativeActivity loads the library too, but without System.loadLibrary the native
|
||||
// methods below would not be found
|
||||
System.loadLibrary("ink_notes");
|
||||
}
|
||||
|
||||
private static final int PICK_FILE = 1;
|
||||
|
||||
static native void nativeCommitText(String text);
|
||||
|
||||
static native void nativeDeleteSurroundingText(int before, int after);
|
||||
|
||||
/** Path of the copied file, or null if nothing was chosen or copying failed. */
|
||||
static native void nativeFilePicked(String path);
|
||||
|
||||
private TextSink textSink;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
textSink = new TextSink(this);
|
||||
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
|
||||
}
|
||||
|
||||
/** Shows or hides the soft keyboard; called from Rust on any thread. */
|
||||
public void showKeyboard(final boolean show) {
|
||||
runOnUiThread(() -> {
|
||||
InputMethodManager imm =
|
||||
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (show) {
|
||||
textSink.requestFocus();
|
||||
imm.restartInput(textSink);
|
||||
imm.showSoftInput(textSink, 0);
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(textSink.getWindowToken(), 0);
|
||||
textSink.clearFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the system file dialog for files of this MIME type ("image/*", "*" + "/*"). */
|
||||
public void pickFile(final String mimeType) {
|
||||
runOnUiThread(() -> {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType(mimeType);
|
||||
try {
|
||||
startActivityForResult(intent, PICK_FILE);
|
||||
} catch (Exception e) {
|
||||
nativeFilePicked(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != PICK_FILE) {
|
||||
return;
|
||||
}
|
||||
final Uri uri = (resultCode == RESULT_OK && data != null) ? data.getData() : null;
|
||||
if (uri == null) {
|
||||
nativeFilePicked(null);
|
||||
return;
|
||||
}
|
||||
// Copying a large file must not block the UI thread
|
||||
new Thread(() -> {
|
||||
String path = null;
|
||||
try {
|
||||
path = copyToCache(uri);
|
||||
} catch (Exception e) {
|
||||
android.util.Log.w("ink-notes", "copying the picked file failed", e);
|
||||
}
|
||||
nativeFilePicked(path);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String copyToCache(Uri uri) throws Exception {
|
||||
String name = "import";
|
||||
try (Cursor cursor = getContentResolver().query(
|
||||
uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
|
||||
name = cursor.getString(0).replace('/', '_');
|
||||
}
|
||||
}
|
||||
File dir = new File(getCacheDir(), "picked");
|
||||
File[] old = dir.listFiles();
|
||||
if (old != null) {
|
||||
for (File f : old) {
|
||||
f.delete();
|
||||
}
|
||||
}
|
||||
dir.mkdirs();
|
||||
File target = new File(dir, name);
|
||||
try (InputStream in = getContentResolver().openInputStream(uri);
|
||||
OutputStream out = new FileOutputStream(target)) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buffer)) > 0) {
|
||||
out.write(buffer, 0, n);
|
||||
}
|
||||
}
|
||||
return target.getAbsolutePath();
|
||||
}
|
||||
|
||||
/** Invisible view that receives the keyboard's text. */
|
||||
private static class TextSink extends View {
|
||||
TextSink(Context context) {
|
||||
super(context);
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCheckIsTextEditor() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||
// Visible password: keyboards commit every character right away instead of
|
||||
// composing words with suggestions, which the app could not show
|
||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
|
||||
| InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
| InputType.TYPE_TEXT_FLAG_MULTI_LINE;
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||
| EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
| EditorInfo.IME_ACTION_NONE;
|
||||
return new TextConnection(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Passes committed text and deletions to Rust; key events go the usual way. */
|
||||
private static class TextConnection extends BaseInputConnection {
|
||||
private String composing = "";
|
||||
|
||||
TextConnection(View view) {
|
||||
super(view, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||
composing = "";
|
||||
if (text.length() > 0) {
|
||||
nativeCommitText(text.toString());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||
// Not shown while composing; taken over once finished
|
||||
composing = text.toString();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean finishComposingText() {
|
||||
if (!composing.isEmpty()) {
|
||||
nativeCommitText(composing);
|
||||
composing = "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||
nativeDeleteSurroundingText(beforeLength, afterLength);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Builds the APK inside the container from packaging/android/Containerfile (Issue #45),
|
||||
# with podman (preferred) or docker. Needs nothing else on the host.
|
||||
# Result: target/ink-notes_<version>_arm64.apk
|
||||
#
|
||||
# Signing key as in scripts/package-android.sh: ANDROID_KEYSTORE (path on the host),
|
||||
# ANDROID_KEYSTORE_PASS, ANDROID_KEY_ALIAS; otherwise a debug key (kept below
|
||||
# target/android-container/home, so that test builds can update each other).
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
ENGINE=podman
|
||||
USER_ARGS=(--userns=keep-id)
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
ENGINE=docker
|
||||
USER_ARGS=(--user "$(id -u):$(id -g)")
|
||||
else
|
||||
echo "FEHLER: weder podman noch docker gefunden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="ink-notes-android"
|
||||
echo "=== Baue Container-Image ${IMAGE} ==="
|
||||
"${ENGINE}" build -t "${IMAGE}" -f packaging/android/Containerfile .
|
||||
|
||||
mkdir -p target/android-container/home
|
||||
ARGS=(
|
||||
--rm "${USER_ARGS[@]}"
|
||||
-v "${ROOT_DIR}:/src"
|
||||
-e HOME=/src/target/android-container/home
|
||||
-e CARGO_TARGET_DIR=/src/target/android-container
|
||||
# Downloaded crates stay between runs
|
||||
-e CARGO_HOME=/src/target/android-container/cargo
|
||||
)
|
||||
if [ -n "${ANDROID_KEYSTORE:-}" ]; then
|
||||
ARGS+=(
|
||||
-v "$(realpath "${ANDROID_KEYSTORE}"):/keystore:ro"
|
||||
-e ANDROID_KEYSTORE=/keystore
|
||||
-e ANDROID_KEYSTORE_PASS -e ANDROID_KEY_ALIAS
|
||||
)
|
||||
fi
|
||||
"${ENGINE}" run "${ARGS[@]}" "${IMAGE}" scripts/package-android.sh
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/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.
|
||||
# without Gradle: the Rust library is built as cdylib with the NDK, javac and d8 compile the
|
||||
# activity (packaging/android/java), aapt2 links the manifest and the icon, zipalign and
|
||||
# apksigner finish the APK. Needs a JDK (javac, keytool).
|
||||
#
|
||||
# Needs the Android SDK (build-tools, platform android-34) and the NDK:
|
||||
# ANDROID_HOME SDK folder (default: ~/android-sdk)
|
||||
@@ -35,12 +36,16 @@ BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/*/ 2>/dev/null | sort -V | tai
|
||||
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
|
||||
for needed in "${NDK_BIN}/${TARGET}${MIN_SDK}-clang" "${BUILD_TOOLS}aapt2" "${BUILD_TOOLS}d8" "${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 javac >/dev/null 2>&1; then
|
||||
echo "FEHLER: javac nicht gefunden (JDK 17 oder neuer)."
|
||||
exit 1
|
||||
fi
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup target add "${TARGET}"
|
||||
fi
|
||||
@@ -55,7 +60,7 @@ 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}"
|
||||
mkdir -p "${WORK}/res/mipmap-xxxhdpi" "${WORK}/compiled" "${WORK}/apk/lib/${ABI}" "${WORK}/classes"
|
||||
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"
|
||||
@@ -65,9 +70,13 @@ sed -e "s/@VERSION_CODE@/${VERSION_CODE}/" -e "s/@VERSION@/${VERSION}/" \
|
||||
--min-sdk-version "${MIN_SDK}" --target-sdk-version "${TARGET_SDK}" \
|
||||
"${WORK}/compiled/res.zip"
|
||||
|
||||
echo "=== [3/4] Native Bibliothek hinzufügen ==="
|
||||
echo "=== [3/4] Activity und native Bibliothek hinzufügen ==="
|
||||
javac -source 8 -target 8 -nowarn -Xlint:-options -classpath "${ANDROID_JAR}" \
|
||||
-d "${WORK}/classes" $(find packaging/android/java -name '*.java')
|
||||
"${BUILD_TOOLS}d8" --min-api "${MIN_SDK}" --lib "${ANDROID_JAR}" --output "${WORK}/apk" \
|
||||
$(find "${WORK}/classes" -name '*.class')
|
||||
cp "${LIB}" "${WORK}/apk/lib/${ABI}/"
|
||||
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk lib)
|
||||
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk classes.dex lib)
|
||||
"${BUILD_TOOLS}zipalign" -f -p 4 "${WORK}/unaligned.apk" "${WORK}/aligned.apk"
|
||||
|
||||
echo "=== [4/4] Signieren ==="
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Installs the Android SDK parts that scripts/package-android.sh needs, in fixed versions
|
||||
# (Issue #45): command-line tools, platform 34, build-tools 34.0.0 and NDK 28.2.
|
||||
# Used by the CI and packaging/android/Containerfile; works for a local SDK as well.
|
||||
#
|
||||
# ANDROID_HOME target folder (default: ~/android-sdk)
|
||||
#
|
||||
# Needs curl, unzip and a JDK 17 or newer (sdkmanager is a Java program).
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
ANDROID_HOME="${ANDROID_HOME:-${HOME}/android-sdk}"
|
||||
CMDLINE_TOOLS_ZIP="commandlinetools-linux-11076708_latest.zip"
|
||||
CMDLINE_TOOLS_SHA256="2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258"
|
||||
PACKAGES=(
|
||||
"platforms;android-34"
|
||||
"build-tools;34.0.0"
|
||||
"ndk;28.2.13676358"
|
||||
)
|
||||
|
||||
SDKMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager"
|
||||
if [ ! -x "${SDKMANAGER}" ]; then
|
||||
echo "=== Lade Android Command-line Tools ==="
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP}"' EXIT
|
||||
curl -sSfL -o "${TMP}/${CMDLINE_TOOLS_ZIP}" \
|
||||
"https://dl.google.com/android/repository/${CMDLINE_TOOLS_ZIP}"
|
||||
echo "${CMDLINE_TOOLS_SHA256} ${TMP}/${CMDLINE_TOOLS_ZIP}" | sha256sum -c -
|
||||
unzip -q "${TMP}/${CMDLINE_TOOLS_ZIP}" -d "${TMP}"
|
||||
mkdir -p "${ANDROID_HOME}/cmdline-tools"
|
||||
rm -rf "${ANDROID_HOME}/cmdline-tools/latest"
|
||||
mv "${TMP}/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest"
|
||||
fi
|
||||
|
||||
echo "=== Installiere ${PACKAGES[*]} nach ${ANDROID_HOME} ==="
|
||||
# sdkmanager ends with an error when `yes` is cut off by a closed pipe; that's fine
|
||||
(yes || true) | "${SDKMANAGER}" --sdk_root="${ANDROID_HOME}" --licenses >/dev/null
|
||||
"${SDKMANAGER}" --sdk_root="${ANDROID_HOME}" --install "${PACKAGES[@]}" >/dev/null
|
||||
echo "Android-SDK bereit: ${ANDROID_HOME}"
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Android: calls into the app's Java activity and what it reports back (Issue #42).
|
||||
//!
|
||||
//! `packaging/android/java/eu/pansi/inknotes/InkActivity.java` extends NativeActivity with
|
||||
//! text input through an input connection (so that keyboards can send any character) and the
|
||||
//! system file dialog. Rust calls its methods through JNI; it calls the `native*` functions
|
||||
//! below, possibly on another thread, so they only queue messages and wake the event loop.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
use jni::sys::jint;
|
||||
use jni::JNIEnv;
|
||||
|
||||
use crate::file_picker::PickerKind;
|
||||
use crate::ui::UiAction;
|
||||
|
||||
enum Message {
|
||||
Text(String),
|
||||
Delete { before: u32, after: u32 },
|
||||
Picked(Option<PathBuf>),
|
||||
}
|
||||
|
||||
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
|
||||
/// What the open file dialog is for
|
||||
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
|
||||
|
||||
fn push(message: Message) {
|
||||
MESSAGES
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(message);
|
||||
if let Some(app) = crate::platform::android_app() {
|
||||
app.create_waker().wake();
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeCommitText(
|
||||
mut env: JNIEnv,
|
||||
_class: JObject,
|
||||
text: JString,
|
||||
) {
|
||||
if let Ok(text) = env.get_string(&text) {
|
||||
push(Message::Text(text.into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeDeleteSurroundingText(
|
||||
_env: JNIEnv,
|
||||
_class: JObject,
|
||||
before: jint,
|
||||
after: jint,
|
||||
) {
|
||||
push(Message::Delete {
|
||||
before: before.max(0) as u32,
|
||||
after: after.max(0) as u32,
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked(
|
||||
mut env: JNIEnv,
|
||||
_class: JObject,
|
||||
path: JString,
|
||||
) {
|
||||
let path = if path.is_null() {
|
||||
None
|
||||
} else {
|
||||
env.get_string(&path)
|
||||
.ok()
|
||||
.map(|p| PathBuf::from(String::from(p)))
|
||||
};
|
||||
push(Message::Picked(path));
|
||||
}
|
||||
|
||||
/// Is a message waiting (then the app draws a new frame to take it)?
|
||||
pub fn has_messages() -> bool {
|
||||
!MESSAGES
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
/// Typed text and deletions as egui input, and the action for a picked file.
|
||||
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
|
||||
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let mut events = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
for message in messages {
|
||||
match message {
|
||||
Message::Text(text) => events.extend(crate::osk::committed_text_events(&text)),
|
||||
Message::Delete { before, after } => {
|
||||
events.extend(crate::osk::deletion_key_events(before, after))
|
||||
}
|
||||
Message::Picked(path) => {
|
||||
let kind = PICKING.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
match (kind, path) {
|
||||
(Some(PickerKind::Image), Some(path)) => {
|
||||
actions.push(UiAction::InsertImageFile(path))
|
||||
}
|
||||
(Some(PickerKind::Import), Some(path)) => {
|
||||
actions.push(UiAction::ImportFile(path))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(events, actions)
|
||||
}
|
||||
|
||||
/// Runs `f` with the JNI environment and the activity; local references it creates are
|
||||
/// freed afterwards.
|
||||
fn with_activity(
|
||||
f: impl FnOnce(&mut JNIEnv, &JObject) -> jni::errors::Result<()>,
|
||||
) -> Result<(), String> {
|
||||
let app = crate::platform::android_app().ok_or("no activity")?;
|
||||
// Safety: pointers to the VM and the activity (a global reference) that live as long as
|
||||
// the app
|
||||
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||
let result = env.with_local_frame(16, |env| f(env, &activity));
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn show_keyboard(show: bool) {
|
||||
let result = with_activity(|env, activity| {
|
||||
env.call_method(
|
||||
activity,
|
||||
"showKeyboard",
|
||||
"(Z)V",
|
||||
&[JValue::Bool(show.into())],
|
||||
)
|
||||
.map(|_| ())
|
||||
});
|
||||
if let Err(err) = result {
|
||||
log::warn!("showKeyboard: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the system file dialog instead of the app's own; false if that is not possible
|
||||
/// (e.g. for choosing a folder).
|
||||
pub fn pick_file(kind: PickerKind) -> bool {
|
||||
let mime_type = match kind {
|
||||
PickerKind::Image => "image/*",
|
||||
// Xournal++, Rnote and Saber files have no MIME type of their own
|
||||
PickerKind::Import => "*/*",
|
||||
PickerKind::Folder => return false,
|
||||
};
|
||||
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = Some(kind);
|
||||
let result = with_activity(|env, activity| {
|
||||
let mime = env.new_string(mime_type)?;
|
||||
env.call_method(
|
||||
activity,
|
||||
"pickFile",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&mime)],
|
||||
)
|
||||
.map(|_| ())
|
||||
});
|
||||
match result {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("pickFile: {err}");
|
||||
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
+521
-553
File diff suppressed because it is too large.
Load diff
@@ -51,6 +51,13 @@ pub fn sanitize_filename_component(title: &str) -> String {
|
||||
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
|
||||
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
|
||||
pub fn default_documents_dir() -> PathBuf {
|
||||
// Android: written into the app's own folder first, then copied to Downloads
|
||||
// (`platform::publish_export`)
|
||||
#[cfg(target_os = "android")]
|
||||
if let Some(dir) = crate::platform::android_app().and_then(|app| app.external_data_path()) {
|
||||
return dir.join("Documents");
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||
if !dir.trim().is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_bridge;
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod autosave;
|
||||
@@ -22,6 +24,7 @@ pub mod paths;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod pdf_writer;
|
||||
pub mod pen;
|
||||
pub mod platform;
|
||||
pub mod rnote;
|
||||
pub mod ruler;
|
||||
@@ -51,6 +54,7 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||
);
|
||||
// Panics would otherwise only go to stderr, which Android discards
|
||||
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
|
||||
platform::set_android_app(app.clone());
|
||||
if let Some(dir) = app.internal_data_path() {
|
||||
paths::set_app_dir(dir);
|
||||
}
|
||||
|
||||
+71
-3
@@ -17,11 +17,39 @@ mod wayland;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use wayland::KeyboardRequester;
|
||||
|
||||
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
/// Android: the soft keyboard is shown only when asked to (Issue #42). Its text comes through
|
||||
/// the activity's input connection (`android_bridge`); keys like Enter or Backspace may also
|
||||
/// arrive as key events through winit.
|
||||
#[cfg(target_os = "android")]
|
||||
pub struct KeyboardRequester;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(target_os = "android")]
|
||||
impl KeyboardRequester {
|
||||
/// # Safety
|
||||
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
|
||||
Some(Self)
|
||||
}
|
||||
|
||||
pub fn request(&mut self) {
|
||||
crate::android_bridge::show_keyboard(true);
|
||||
}
|
||||
|
||||
pub fn release(&mut self) {
|
||||
crate::android_bridge::show_keyboard(false);
|
||||
}
|
||||
|
||||
/// Typed text and deletions; taking them is left to the app's frame (see `app.rs`)
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
pub struct KeyboardRequester;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
impl KeyboardRequester {
|
||||
/// # Safety
|
||||
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||
@@ -31,6 +59,8 @@ impl KeyboardRequester {
|
||||
|
||||
pub fn request(&mut self) {}
|
||||
|
||||
pub fn release(&mut self) {}
|
||||
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -75,6 +105,30 @@ pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// egui input for committed text: line breaks become the Enter key, as egui's text fields
|
||||
/// take them only that way.
|
||||
pub fn committed_text_events(text: &str) -> Vec<egui::Event> {
|
||||
let mut events = Vec::new();
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
for pressed in [true, false] {
|
||||
events.push(egui::Event::Key {
|
||||
key: egui::Key::Enter,
|
||||
physical_key: None,
|
||||
pressed,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
}
|
||||
}
|
||||
let line = line.trim_end_matches('\r');
|
||||
if !line.is_empty() {
|
||||
events.push(egui::Event::Text(line.to_string()));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
|
||||
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
|
||||
/// zugeklappt wurde).
|
||||
@@ -165,6 +219,20 @@ mod tests {
|
||||
assert!(deletion_key_events(0, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_line_breaks_become_enter() {
|
||||
let events = committed_text_events("ab\nc\n");
|
||||
let kinds: Vec<String> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
egui::Event::Text(t) => t.clone(),
|
||||
egui::Event::Key { pressed: true, .. } => "⏎".into(),
|
||||
_ => "-".into(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["ab", "⏎", "-", "c", "⏎", "-"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_request_without_focused_text_field() {
|
||||
assert!(!should_request(false, false, true));
|
||||
|
||||
@@ -92,6 +92,10 @@ impl KeyboardRequester {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text field lost focus. Nothing to do: the compositor hides the keyboard once
|
||||
/// winit disables text input.
|
||||
pub fn release(&mut self) {}
|
||||
|
||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||
/// aufrufen und an die egui-Eingabe anhängen)
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
//! Pen input independent of the platform (Issue #44).
|
||||
//!
|
||||
//! On the desktop the pen comes from octotablet (Wayland/X11 tablet protocols, Windows Ink),
|
||||
//! on Android as touch samples with the tool type stylus or eraser, which the patched winit
|
||||
//! hands over separately (`winit::platform::android::take_pen_samples`). Both are turned into
|
||||
//! the same [`PenInput`] events, which the app handles in one place.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What happened to the pen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PenEvent {
|
||||
/// Pen comes into range (hovers above the screen)
|
||||
In,
|
||||
/// Pen left the range
|
||||
Out,
|
||||
/// Tip touches the screen (at the position of the last `Pose`)
|
||||
Down,
|
||||
/// Tip lifted
|
||||
Up,
|
||||
/// Position in logical pixels; pressure 0..=1 if known
|
||||
Pose {
|
||||
position: [f32; 2],
|
||||
pressure: Option<f32>,
|
||||
},
|
||||
/// Pen button pressed or released; `key` identifies the button across sessions
|
||||
Button { key: u64, pressed: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PenInput {
|
||||
/// Identifies the pen (several pens can be in use on the desktop)
|
||||
pub tool: u64,
|
||||
/// Eraser end of the pen
|
||||
pub is_eraser: bool,
|
||||
pub event: PenEvent,
|
||||
}
|
||||
|
||||
fn hash_of(value: impl std::hash::Hash) -> u64 {
|
||||
use std::hash::Hasher;
|
||||
// DefaultHasher::new() uses fixed keys: same input, same value in every session
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
value.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Turns octotablet's events into [`PenInput`]s, with each `Down` after the `Pose` of the
|
||||
/// same frame (see [`crate::platform::down_after_pose`]).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn from_octotablet<'a>(
|
||||
events: impl IntoIterator<Item = octotablet::events::Event<'a>>,
|
||||
) -> Vec<PenInput> {
|
||||
use octotablet::events::{Event, ToolEvent};
|
||||
|
||||
let inputs = events.into_iter().filter_map(|event| {
|
||||
let Event::Tool { tool, event } = event else {
|
||||
return None;
|
||||
};
|
||||
let event = match event {
|
||||
ToolEvent::In { .. } => PenEvent::In,
|
||||
ToolEvent::Out | ToolEvent::Removed => PenEvent::Out,
|
||||
ToolEvent::Down => PenEvent::Down,
|
||||
ToolEvent::Up => PenEvent::Up,
|
||||
ToolEvent::Pose(pose) => PenEvent::Pose {
|
||||
position: pose.position,
|
||||
pressure: pose.pressure.get(),
|
||||
},
|
||||
ToolEvent::Button { button_id, pressed } => PenEvent::Button {
|
||||
key: hash_of(button_id),
|
||||
pressed,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(PenInput {
|
||||
tool: hash_of(tool.id()),
|
||||
is_eraser: tool.tool_type == Some(octotablet::tool::Type::Eraser),
|
||||
event,
|
||||
})
|
||||
});
|
||||
crate::platform::down_after_pose(
|
||||
inputs,
|
||||
|i| i.event == PenEvent::Down,
|
||||
|i| matches!(i.event, PenEvent::Pose { .. }),
|
||||
)
|
||||
}
|
||||
|
||||
/// What an Android pen sample reports (mirrors `winit::platform::android::PenAction`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SampleAction {
|
||||
HoverEnter,
|
||||
HoverMove,
|
||||
HoverExit,
|
||||
Down,
|
||||
Move,
|
||||
Up,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// One Android pen sample (mirrors `winit::platform::android::PenSample`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Sample {
|
||||
pub action: SampleAction,
|
||||
/// Physical pixels
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub pressure: f32,
|
||||
pub eraser: bool,
|
||||
/// `MotionEvent.getButtonState()`
|
||||
pub buttons: u32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl From<winit::platform::android::PenSample> for Sample {
|
||||
fn from(s: winit::platform::android::PenSample) -> Self {
|
||||
use winit::platform::android::PenAction as A;
|
||||
Self {
|
||||
action: match s.action {
|
||||
A::HoverEnter => SampleAction::HoverEnter,
|
||||
A::HoverMove => SampleAction::HoverMove,
|
||||
A::HoverExit => SampleAction::HoverExit,
|
||||
A::Down => SampleAction::Down,
|
||||
A::Move => SampleAction::Move,
|
||||
A::Up => SampleAction::Up,
|
||||
A::Cancel => SampleAction::Cancel,
|
||||
},
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
pressure: s.pressure,
|
||||
eraser: s.eraser,
|
||||
buttons: s.buttons,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two pen buttons of Android's `MotionEvent` (`BUTTON_STYLUS_PRIMARY`/`SECONDARY`).
|
||||
const ANDROID_BUTTONS: [(u32, &str); 2] = [
|
||||
(0x20, "android-stylus-primary"),
|
||||
(0x40, "android-stylus-secondary"),
|
||||
];
|
||||
|
||||
/// How long the pen may be out of sight before it counts as gone. Android reports
|
||||
/// `HoverExit` right before the tip touches down, and pens without hover report nothing
|
||||
/// after lifting.
|
||||
const ANDROID_OUT_DELAY: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Turns Android pen samples into [`PenInput`]s.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AndroidPen {
|
||||
in_range: bool,
|
||||
down: bool,
|
||||
eraser: bool,
|
||||
buttons: u32,
|
||||
/// Since when the pen is neither hovering nor touching
|
||||
gone_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AndroidPen {
|
||||
/// The single pen Android reports
|
||||
const TOOL: u64 = 1;
|
||||
|
||||
fn push(&self, out: &mut Vec<PenInput>, event: PenEvent) {
|
||||
out.push(PenInput {
|
||||
tool: Self::TOOL,
|
||||
is_eraser: self.eraser,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
/// Events for `samples` (in this order); `scale` converts physical to logical pixels.
|
||||
pub fn convert(&mut self, samples: &[Sample], scale: f32, now: Instant) -> Vec<PenInput> {
|
||||
let mut out = Vec::new();
|
||||
for sample in samples {
|
||||
self.eraser = sample.eraser;
|
||||
for (mask, name) in ANDROID_BUTTONS {
|
||||
let pressed = sample.buttons & mask != 0;
|
||||
if pressed != (self.buttons & mask != 0) {
|
||||
self.push(
|
||||
&mut out,
|
||||
PenEvent::Button {
|
||||
key: hash_of(name),
|
||||
pressed,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
self.buttons = sample.buttons;
|
||||
|
||||
let position = [sample.x / scale, sample.y / scale];
|
||||
let touching = Some(sample.pressure.clamp(0.0, 1.0));
|
||||
match sample.action {
|
||||
SampleAction::HoverEnter | SampleAction::HoverMove => {
|
||||
self.gone_since = None;
|
||||
self.enter(&mut out);
|
||||
let pose = PenEvent::Pose {
|
||||
position,
|
||||
pressure: None,
|
||||
};
|
||||
self.push(&mut out, pose);
|
||||
}
|
||||
SampleAction::HoverExit => {
|
||||
if !self.down {
|
||||
self.gone_since.get_or_insert(now);
|
||||
}
|
||||
}
|
||||
SampleAction::Down => {
|
||||
self.gone_since = None;
|
||||
self.enter(&mut out);
|
||||
let pose = PenEvent::Pose {
|
||||
position,
|
||||
pressure: touching,
|
||||
};
|
||||
self.push(&mut out, pose);
|
||||
if !self.down {
|
||||
self.down = true;
|
||||
self.push(&mut out, PenEvent::Down);
|
||||
}
|
||||
}
|
||||
SampleAction::Move => {
|
||||
self.gone_since = None;
|
||||
self.enter(&mut out);
|
||||
let pressure = if self.down { touching } else { None };
|
||||
self.push(&mut out, PenEvent::Pose { position, pressure });
|
||||
}
|
||||
SampleAction::Up | SampleAction::Cancel => {
|
||||
if self.down {
|
||||
if sample.action == SampleAction::Up {
|
||||
let pose = PenEvent::Pose {
|
||||
position,
|
||||
pressure: touching,
|
||||
};
|
||||
self.push(&mut out, pose);
|
||||
}
|
||||
self.down = false;
|
||||
self.push(&mut out, PenEvent::Up);
|
||||
}
|
||||
self.gone_since = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(self.poll(now));
|
||||
out
|
||||
}
|
||||
|
||||
fn enter(&mut self, out: &mut Vec<PenInput>) {
|
||||
if !self.in_range {
|
||||
self.in_range = true;
|
||||
self.push(out, PenEvent::In);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Out` once the pen has been gone for a moment; call regularly while it is in range.
|
||||
pub fn poll(&mut self, now: Instant) -> Vec<PenInput> {
|
||||
let mut out = Vec::new();
|
||||
let gone = self
|
||||
.gone_since
|
||||
.is_some_and(|since| now.duration_since(since) >= ANDROID_OUT_DELAY);
|
||||
if gone && self.in_range && !self.down {
|
||||
self.in_range = false;
|
||||
self.gone_since = None;
|
||||
if self.buttons != 0 {
|
||||
for (mask, name) in ANDROID_BUTTONS {
|
||||
if self.buttons & mask != 0 {
|
||||
self.push(
|
||||
&mut out,
|
||||
PenEvent::Button {
|
||||
key: hash_of(name),
|
||||
pressed: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
self.buttons = 0;
|
||||
}
|
||||
self.push(&mut out, PenEvent::Out);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(action: SampleAction, x: f32, pressure: f32, buttons: u32) -> Sample {
|
||||
Sample {
|
||||
action,
|
||||
x,
|
||||
y: 10.0,
|
||||
pressure,
|
||||
eraser: false,
|
||||
buttons,
|
||||
}
|
||||
}
|
||||
|
||||
fn kinds(inputs: &[PenInput]) -> Vec<&'static str> {
|
||||
inputs
|
||||
.iter()
|
||||
.map(|i| match i.event {
|
||||
PenEvent::In => "in",
|
||||
PenEvent::Out => "out",
|
||||
PenEvent::Down => "down",
|
||||
PenEvent::Up => "up",
|
||||
PenEvent::Pose { pressure: None, .. } => "hover",
|
||||
PenEvent::Pose { .. } => "pose",
|
||||
PenEvent::Button { pressed: true, .. } => "button-down",
|
||||
PenEvent::Button { .. } => "button-up",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_android_stroke_with_hover() {
|
||||
use SampleAction::*;
|
||||
let mut pen = AndroidPen::default();
|
||||
let t = Instant::now();
|
||||
let ms = |n| t + Duration::from_millis(n);
|
||||
|
||||
let out = pen.convert(&[sample(HoverEnter, 20.0, 0.0, 0)], 2.0, t);
|
||||
assert_eq!(kinds(&out), ["in", "hover"]);
|
||||
assert_eq!(
|
||||
out[1].event,
|
||||
PenEvent::Pose {
|
||||
position: [10.0, 5.0],
|
||||
pressure: None
|
||||
}
|
||||
);
|
||||
|
||||
// Android ends the hover right before the tip touches: no Out in between
|
||||
let out = pen.convert(&[sample(HoverExit, 20.0, 0.0, 0)], 2.0, ms(5));
|
||||
assert!(out.is_empty());
|
||||
let out = pen.convert(
|
||||
&[sample(Down, 20.0, 0.4, 0), sample(Move, 30.0, 0.5, 0)],
|
||||
2.0,
|
||||
ms(10),
|
||||
);
|
||||
assert_eq!(kinds(&out), ["pose", "down", "pose"]);
|
||||
assert!(pen.poll(ms(500)).is_empty(), "still touching");
|
||||
|
||||
let out = pen.convert(&[sample(Up, 30.0, 0.0, 0)], 2.0, ms(600));
|
||||
assert_eq!(kinds(&out), ["pose", "up"]);
|
||||
let out = pen.convert(&[sample(HoverEnter, 30.0, 0.0, 0)], 2.0, ms(610));
|
||||
assert_eq!(kinds(&out), ["hover"]);
|
||||
|
||||
pen.convert(&[sample(HoverExit, 30.0, 0.0, 0)], 2.0, ms(700));
|
||||
assert!(pen.poll(ms(750)).is_empty());
|
||||
assert_eq!(kinds(&pen.poll(ms(900))), ["out"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_android_pen_without_hover_leaves_after_lifting() {
|
||||
use SampleAction::*;
|
||||
let mut pen = AndroidPen::default();
|
||||
let t = Instant::now();
|
||||
let out = pen.convert(&[sample(Down, 0.0, 0.7, 0)], 1.0, t);
|
||||
assert_eq!(kinds(&out), ["in", "pose", "down"]);
|
||||
pen.convert(&[sample(Up, 0.0, 0.0, 0)], 1.0, t);
|
||||
assert_eq!(kinds(&pen.poll(t + Duration::from_millis(200))), ["out"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_android_buttons_and_cancel() {
|
||||
use SampleAction::*;
|
||||
let mut pen = AndroidPen::default();
|
||||
let t = Instant::now();
|
||||
let out = pen.convert(
|
||||
&[
|
||||
sample(HoverMove, 0.0, 0.0, 0x20),
|
||||
sample(Down, 0.0, 0.5, 0x20),
|
||||
sample(Cancel, 0.0, 0.0, 0x20),
|
||||
],
|
||||
1.0,
|
||||
t,
|
||||
);
|
||||
assert_eq!(
|
||||
kinds(&out),
|
||||
["button-down", "in", "hover", "pose", "down", "up"]
|
||||
);
|
||||
// Pen gone with the button still held: released before Out
|
||||
let out = pen.poll(t + Duration::from_millis(200));
|
||||
assert_eq!(kinds(&out), ["button-up", "out"]);
|
||||
}
|
||||
}
|
||||
+141
@@ -127,6 +127,147 @@ where
|
||||
event_loop.run(handler)
|
||||
}
|
||||
|
||||
/// The Android activity, for calls winit doesn't offer (soft keyboard, Issue #42).
|
||||
#[cfg(target_os = "android")]
|
||||
static ANDROID_APP: std::sync::OnceLock<winit::platform::android::activity::AndroidApp> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Remembers the activity; called once by `android_main`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn set_android_app(app: winit::platform::android::activity::AndroidApp) {
|
||||
let _ = ANDROID_APP.set(app);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn android_app() -> Option<winit::platform::android::activity::AndroidApp> {
|
||||
ANDROID_APP.get().cloned()
|
||||
}
|
||||
|
||||
/// Makes an exported file available to the user. Desktop: it already is (the path is
|
||||
/// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and
|
||||
/// newer), as other apps can't see the app's own folder; older versions keep it there.
|
||||
pub fn publish_export(path: std::path::PathBuf) -> Result<std::path::PathBuf, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
match android_files::publish_to_downloads(&path) {
|
||||
Ok(location) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Ok(location);
|
||||
}
|
||||
Err(err) => log::warn!("export not copied to Downloads: {err}"),
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_files {
|
||||
use jni::objects::{JObject, JValue};
|
||||
use jni::JNIEnv;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Folder below the shared storage the exports go to
|
||||
const RELATIVE_PATH: &str = "Download/ink-notes";
|
||||
|
||||
fn mime_type(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("png") => "image/png",
|
||||
Some("svg") => "image/svg+xml",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish_to_downloads(path: &Path) -> Result<PathBuf, String> {
|
||||
let app = super::android_app().ok_or("no activity")?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("invalid file name")?
|
||||
.to_string();
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
// Safety: pointers to the VM and the activity (a global reference) that live as long
|
||||
// as the app
|
||||
let vm =
|
||||
unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
|
||||
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||
let result = insert(&mut env, &activity, &name, mime_type(path), &bytes);
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
result.map_err(|e| e.to_string())?;
|
||||
Ok(Path::new(RELATIVE_PATH).join(name))
|
||||
}
|
||||
|
||||
fn insert(
|
||||
env: &mut JNIEnv,
|
||||
activity: &JObject,
|
||||
name: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> jni::errors::Result<()> {
|
||||
let sdk = env
|
||||
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")?
|
||||
.i()?;
|
||||
if sdk < 29 {
|
||||
return Err(jni::errors::Error::JniCall(jni::errors::JniError::Unknown));
|
||||
}
|
||||
let values = env.new_object("android/content/ContentValues", "()V", &[])?;
|
||||
for (key, value) in [
|
||||
("_display_name", name),
|
||||
("mime_type", mime),
|
||||
("relative_path", RELATIVE_PATH),
|
||||
] {
|
||||
let key = env.new_string(key)?;
|
||||
let value = env.new_string(value)?;
|
||||
env.call_method(
|
||||
&values,
|
||||
"put",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[JValue::Object(&key), JValue::Object(&value)],
|
||||
)?;
|
||||
}
|
||||
let resolver = env
|
||||
.call_method(
|
||||
activity,
|
||||
"getContentResolver",
|
||||
"()Landroid/content/ContentResolver;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let collection = env
|
||||
.get_static_field(
|
||||
"android/provider/MediaStore$Downloads",
|
||||
"EXTERNAL_CONTENT_URI",
|
||||
"Landroid/net/Uri;",
|
||||
)?
|
||||
.l()?;
|
||||
let uri = env
|
||||
.call_method(
|
||||
&resolver,
|
||||
"insert",
|
||||
"(Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri;",
|
||||
&[JValue::Object(&collection), JValue::Object(&values)],
|
||||
)?
|
||||
.l()?;
|
||||
if uri.is_null() {
|
||||
return Err(jni::errors::Error::NullPtr("MediaStore insert"));
|
||||
}
|
||||
let stream = env
|
||||
.call_method(
|
||||
&resolver,
|
||||
"openOutputStream",
|
||||
"(Landroid/net/Uri;)Ljava/io/OutputStream;",
|
||||
&[JValue::Object(&uri)],
|
||||
)?
|
||||
.l()?;
|
||||
let array = env.byte_array_from_slice(bytes)?;
|
||||
env.call_method(&stream, "write", "([B)V", &[JValue::Object(&array)])?;
|
||||
env.call_method(&stream, "close", "()V", &[])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -690,6 +690,11 @@ impl UiState {
|
||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||
let dir = self.last_picker_dir.clone();
|
||||
self.close_overlays();
|
||||
// Android: the app can't look into other apps' folders; the system dialog can
|
||||
#[cfg(target_os = "android")]
|
||||
if crate::android_bridge::pick_file(kind) {
|
||||
return;
|
||||
}
|
||||
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
|
||||
}
|
||||
|
||||
|
||||
Vendored
+35
@@ -77,6 +77,41 @@ impl<T> EventLoopBuilderExtAndroid for EventLoopBuilder<T> {
|
||||
/// depending on the `android_activity` crate, and instead consume the API that
|
||||
/// is re-exported by Winit.
|
||||
///
|
||||
/// ink-notes patch: what a pen sample reports (Issue #44).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PenAction {
|
||||
/// Pen comes near the screen, moves above it or leaves it
|
||||
HoverEnter,
|
||||
HoverMove,
|
||||
HoverExit,
|
||||
/// Pen touches the screen, moves on it, is lifted or the gesture is cancelled
|
||||
Down,
|
||||
Move,
|
||||
Up,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// ink-notes patch: one sample of a pen (tool type stylus or eraser). Such pointers are not
|
||||
/// reported as `WindowEvent::Touch`; the app collects them with [`take_pen_samples`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PenSample {
|
||||
pub action: PenAction,
|
||||
/// Position in physical pixels
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Usually 0..=1
|
||||
pub pressure: f32,
|
||||
/// Eraser end of the pen (tool type eraser)
|
||||
pub eraser: bool,
|
||||
/// `MotionEvent.getButtonState()`; the pen buttons are 0x20 (primary) and 0x40 (secondary)
|
||||
pub buttons: u32,
|
||||
}
|
||||
|
||||
/// ink-notes patch: the pen samples received since the last call, oldest first.
|
||||
pub fn take_pen_samples() -> Vec<PenSample> {
|
||||
crate::platform_impl::take_pen_samples()
|
||||
}
|
||||
|
||||
/// For compatibility applications should then import the `AndroidApp` type for
|
||||
/// their `android_main(app: AndroidApp)` function like:
|
||||
/// ```rust
|
||||
|
||||
+68
-2
@@ -11,7 +11,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction};
|
||||
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction, ToolType};
|
||||
use android_activity::{
|
||||
AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect,
|
||||
};
|
||||
@@ -382,6 +382,7 @@ impl<T: 'static> EventLoop<T> {
|
||||
InputEvent::MotionEvent(motion_event) => {
|
||||
let window_id = window::WindowId(WindowId);
|
||||
let device_id = event::DeviceId(DeviceId(motion_event.device_id()));
|
||||
record_pen_samples(motion_event);
|
||||
|
||||
let phase = match motion_event.action() {
|
||||
MotionAction::Down | MotionAction::PointerDown => {
|
||||
@@ -408,6 +409,10 @@ impl<T: 'static> EventLoop<T> {
|
||||
};
|
||||
|
||||
for pointer in pointers {
|
||||
// ink-notes patch: pens are reported through `take_pen_samples`
|
||||
if is_pen(&pointer) {
|
||||
continue;
|
||||
}
|
||||
let location = PhysicalPosition {
|
||||
x: pointer.x() as _,
|
||||
y: pointer.y() as _,
|
||||
@@ -450,6 +455,15 @@ impl<T: 'static> EventLoop<T> {
|
||||
&mut self.combining_accent,
|
||||
);
|
||||
|
||||
// ink-notes patch: the character typed, so that text fields receive it
|
||||
let text = match (state, &key_char) {
|
||||
(
|
||||
event::ElementState::Pressed,
|
||||
Some(android_activity::input::KeyMapChar::Unicode(c)),
|
||||
) if !c.is_control() => Some(smol_str::SmolStr::from_iter([*c])),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let event = event::Event::WindowEvent {
|
||||
window_id: window::WindowId(WindowId),
|
||||
event: event::WindowEvent::KeyboardInput {
|
||||
@@ -460,7 +474,7 @@ impl<T: 'static> EventLoop<T> {
|
||||
logical_key: keycodes::to_logical(key_char, keycode),
|
||||
location: keycodes::to_location(keycode),
|
||||
repeat: key.repeat_count() > 0,
|
||||
text: None,
|
||||
text,
|
||||
platform_specific: KeyEventExtra {},
|
||||
},
|
||||
is_synthetic: false,
|
||||
@@ -1124,3 +1138,55 @@ impl VideoMode {
|
||||
self.monitor.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// ink-notes patch: pen samples waiting for `take_pen_samples` (Issue #44)
|
||||
static PEN_SAMPLES: Mutex<Vec<crate::platform::android::PenSample>> = Mutex::new(Vec::new());
|
||||
|
||||
fn is_pen(pointer: &android_activity::input::Pointer<'_>) -> bool {
|
||||
matches!(pointer.tool_type(), ToolType::Stylus | ToolType::Eraser)
|
||||
}
|
||||
|
||||
fn record_pen_samples(motion_event: &android_activity::input::MotionEvent<'_>) {
|
||||
use crate::platform::android::{PenAction, PenSample};
|
||||
|
||||
let action = match motion_event.action() {
|
||||
MotionAction::Down | MotionAction::PointerDown => PenAction::Down,
|
||||
MotionAction::Up | MotionAction::PointerUp => PenAction::Up,
|
||||
MotionAction::Move => PenAction::Move,
|
||||
MotionAction::Cancel => PenAction::Cancel,
|
||||
MotionAction::HoverEnter => PenAction::HoverEnter,
|
||||
MotionAction::HoverMove => PenAction::HoverMove,
|
||||
MotionAction::HoverExit => PenAction::HoverExit,
|
||||
_ => return,
|
||||
};
|
||||
let buttons = motion_event.button_state().0;
|
||||
let mut record = |pointer: android_activity::input::Pointer<'_>| {
|
||||
if !is_pen(&pointer) {
|
||||
return;
|
||||
}
|
||||
let sample = PenSample {
|
||||
action,
|
||||
x: pointer.x(),
|
||||
y: pointer.y(),
|
||||
pressure: pointer.pressure(),
|
||||
eraser: pointer.tool_type() == ToolType::Eraser,
|
||||
buttons,
|
||||
};
|
||||
let mut samples = PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Nobody collects them (app without pen support): don't grow without bound
|
||||
if samples.len() >= 4096 {
|
||||
samples.remove(0);
|
||||
}
|
||||
samples.push(sample);
|
||||
};
|
||||
match action {
|
||||
PenAction::Down | PenAction::Up => {
|
||||
record(motion_event.pointer_at_index(motion_event.pointer_index()))
|
||||
}
|
||||
_ => motion_event.pointers().for_each(record),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_pen_samples() -> Vec<crate::platform::android::PenSample> {
|
||||
std::mem::take(&mut *PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
Reference in new issue
Block a user