diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml new file mode 100644 index 0000000..a8edc4d --- /dev/null +++ b/.github/actions/android-build/action.yml @@ -0,0 +1,80 @@ +name: Android build environment +description: Shared Buildozer dependencies and cache for APK and AAB builds. +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y git zip unzip autoconf automake autopoint gettext \ + libtool pkg-config zlib1g-dev libncurses-dev libtinfo6 cmake libffi-dev \ + libssl-dev build-essential ccache libltdl-dev + - name: Install pinned Buildozer toolchain + shell: bash + run: python -m pip install -r requirements-build.txt + - name: Constrain both p4a dependency resolution and installation + shell: bash + run: echo "PIP_CONSTRAINT=$GITHUB_WORKSPACE/constraints-android.txt" >> "$GITHUB_ENV" + - name: Restore Buildozer cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.buildozer + ~/.android + .buildozer + key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt', 'constraints-android.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt', 'constraints-android.txt') }}- + purenote-android-v4-${{ runner.os }}- + buildozer-v3- + - name: Recreate disposable p4a installer environments + shell: bash + run: | + python - <<'PY' + import shutil + from pathlib import Path + + # Re-running venv over a cached, upgraded pip can leave mixed pip files. + # Keep compiled recipes; only discard the pure-Python installer venv. + for environment in Path('.buildozer/android/platform').glob('build-*/build/venv'): + shutil.rmtree(environment) + PY + - name: Use a fresh pip environment for each ABI + shell: bash + run: | + python - <<'PY' + import configparser + import os + import subprocess + from pathlib import Path + + config = configparser.ConfigParser(interpolation=None) + config.read('buildozer.spec') + commit = config['app']['p4a.commit'] + source = Path('.buildozer/android/platform/python-for-android').resolve() + if not (source / '.git').is_dir(): + source.parent.mkdir(parents=True, exist_ok=True) + subprocess.run(['git', 'clone', '--no-checkout', + 'https://github.com/kivy/python-for-android.git', str(source)], check=True) + if subprocess.run(['git', '-C', str(source), 'cat-file', '-e', commit + '^{commit}'], + capture_output=True).returncode: + subprocess.run(['git', '-C', str(source), 'fetch', 'origin', commit], check=True) + subprocess.run(['git', '-C', str(source), 'reset', '--hard', commit], check=True) + build = source / 'pythonforandroid/build.py' + before = "shprint(host_python, '-m', 'venv', 'venv')" + after = "shprint(host_python, '-m', 'venv', '--clear', 'venv')" + text = build.read_text() + if text.count(before) != 1: + raise RuntimeError('The pinned p4a venv code changed; review the workaround') + build.write_text(text.replace(before, after)) + with open(os.environ['GITHUB_ENV'], 'a') as environment: + environment.write(f'APP_P4A_SOURCE_DIR={source}\n') + PY diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..3840608 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,71 @@ +name: Build PureNote APK +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + inputs: + architecture: + description: 'ARM64 per telefono oppure x86_64 per emulatore' + type: choice + default: arm64-v8a + options: + - arm64-v8a + - x86_64 +permissions: + contents: read +concurrency: + group: apk-${{ github.ref }}-${{ inputs.architecture || 'arm64-v8a' }} + cancel-in-progress: true +jobs: + checks: + uses: ./.github/workflows/test.yml + build-android: + needs: checks + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + PYTHONFORANDROID_PREREQUISITES_INSTALL_INTERACTIVE: '0' + steps: + - uses: actions/checkout@v4 + - name: Select emulator architecture + if: inputs.architecture == 'x86_64' + run: sed -i 's/^android.archs = .*/android.archs = x86_64/' buildozer.spec + - uses: ./.github/actions/android-build + - name: Restore optional persistent debug signing key + shell: bash + env: + ANDROID_DEBUG_KEYSTORE_B64: ${{ secrets.ANDROID_DEBUG_KEYSTORE_B64 }} + run: | + if [ -n "$ANDROID_DEBUG_KEYSTORE_B64" ]; then + key_path="$RUNNER_TEMP/purenote-debug.keystore" + printf '%s' "$ANDROID_DEBUG_KEYSTORE_B64" | base64 --decode > "$key_path" + chmod 600 "$key_path" + echo "PURENOTE_DEBUG_KEYSTORE=$key_path" >> "$GITHUB_ENV" + fi + - name: Build debug APK + shell: bash + run: buildozer -v android debug + - name: Sign APK with persistent debug identity + if: env.PURENOTE_DEBUG_KEYSTORE != '' + run: python tests/sign_apk.py --keystore "$PURENOTE_DEBUG_KEYSTORE" bin/*.apk + - name: Verify APK signature and permissions + run: python tests/verify_apk.py bin/*.apk + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: purenote-debug-apk + path: bin/*.apk + if-no-files-found: error + - name: Save build progress + if: always() + uses: actions/cache/save@v4 + with: + path: | + ~/.buildozer + ~/.android + .buildozer + key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt', 'constraints-android.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} + - name: Remove temporary signing key + if: always() + run: rm -f "$RUNNER_TEMP/purenote-debug.keystore" diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..3d5b63f --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,47 @@ +name: Build PureNote Release AAB +on: + workflow_dispatch: + push: + tags: ['v*'] +permissions: + contents: read +jobs: + checks: + uses: ./.github/workflows/test.yml + build-release: + needs: checks + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + PYTHONFORANDROID_PREREQUISITES_INSTALL_INTERACTIVE: '0' + steps: + - uses: actions/checkout@v4 + - name: Select release architectures before computing the cache key + run: sed -i 's/^android.archs = .*/android.archs = arm64-v8a,armeabi-v7a/' buildozer.spec + - uses: ./.github/actions/android-build + - name: Build release AAB and APK for local signing + shell: bash + run: | + buildozer -v android release + buildozer --profile release-apk -v android release + - name: Upload AAB for local signing + uses: actions/upload-artifact@v4 + with: + name: purenote-release-aab-for-local-signing + path: bin/*.aab + if-no-files-found: error + - name: Upload APK for local signing + uses: actions/upload-artifact@v4 + with: + name: purenote-release-apk-for-local-signing + path: bin/*.apk + if-no-files-found: error + - name: Save build progress + if: always() + uses: actions/cache/save@v4 + with: + path: | + ~/.buildozer + ~/.android + .buildozer + key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt', 'constraints-android.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..442ff12 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Test PureNote +on: + workflow_call: + workflow_dispatch: +permissions: + contents: read +jobs: + tests: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements-dev.txt + - run: python -m pip install -r requirements-dev.txt + - run: ruff check . + - run: python -m pytest -q + - name: Install UI test dependencies + run: sudo apt-get update && sudo apt-get install -y xvfb xauth libgl1-mesa-dri libegl1 libmtdev1 + - name: Exercise the real Kivy interface + env: + KIVY_NO_CONSOLELOG: '1' + LIBGL_ALWAYS_SOFTWARE: '1' + PURENOTE_SCREENSHOTS: ${{ runner.temp }}/purenote-preview + run: xvfb-run -a python tests/ui_smoke.py + - name: Check Android density and text at 200 percent + env: + KIVY_NO_CONSOLELOG: '1' + LIBGL_ALWAYS_SOFTWARE: '1' + KIVY_METRICS_DENSITY: '2.625' + KIVY_METRICS_FONTSCALE: '2' + PURENOTE_SCREENSHOTS: ${{ runner.temp }}/purenote-preview/enlarged-text + run: xvfb-run -a -s '-screen 0 1440x2560x24' python tests/ui_smoke.py + - name: Upload UI previews + if: always() + uses: actions/upload-artifact@v4 + with: + name: purenote-ui-preview + path: ${{ runner.temp }}/purenote-preview/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ca04fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +.venv/ +.build-venv/ +__pycache__/ +*.pyc +*.bak +*.orig +notes.db +notes.db.orig.bak +PureNote_export/ +.buildozer/ +bin/ +.DS_Store +.pytest_cache/ +.ruff_cache/ +*.db-wal +*.db-shm +*.keystore +*.jks +build.log +*.aab +*.apk +*.apks +*.idsig +.env +.env.* +*.pem +*.p12 +*.pfx +secrets/ +credentials*.json +*service-account*.json +notes.db.* diff --git a/README.md b/README.md index e244cda..7161663 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,128 @@ # PureNote -Basic note-taking application for Android, built with Python and Kivy. Store your notes locally for quick access and privacy. Android app. -# Features -Create and Edit Notes -Save Notes Locally -Counter letters in the notes -Easy Navigation +Un’app di note Android scritta in Python e Kivy. Rétro sobrio, un solo campo di +scrittura, nessun account. Le note restano in un database SQLite privato. +

+ Editor PureNote + Lista delle note +

-# Screenshot -![Screenshot dell'app](images/Screenshot_PureNote.png), ![Screenshot dell'app](images/Screenshot2_PureNote.png) +## Scrivere e ritrovare +- All’apertura ritrovi l’ultima nota. Una nuova installazione parte da un foglio vuoto. +- La prima riga non vuota dà il titolo alla nota; non c’è un campo titolo separato. +- Autosalvataggio dopo 300 ms senza modifiche e almeno ogni 2 secondi durante + la digitazione continua. Cambio nota, background e chiusura normale forzano il salvataggio. +- Lista ordinata per ultima modifica, ricerca nel testo e condivisione Android. +- Cestino con ripristino, annullamento per 8 secondi e conferma prima di eliminare definitivamente. +- Backup JSON esportabile e ripristinabile tramite il selettore file di Android, + senza permessi generali per accedere alla memoria. -# Used -Python -Kivy -Buildozer +Il backup automatico di sistema Android è disabilitato: l’esportazione manuale +permette di scegliere dove conservare una copia delle note. -# Installation -To install the app on your Android device, download the APK file and follow the installation prompts. -[![Play Store](images/google-play-badge-PureNote.png)](https://play.google.com/store/apps/details?id=bbnss.test.purenote&pcampaignid=web_share) +Se un salvataggio fallisce, il testo resta nell’editor: l’app riprova e blocca +il cambio nota. Il menu permette di condividere il testo anche in questo caso. +L’arresto forzato del processo può perdere le modifiche ancora in attesa di salvataggio. +Un foglio nuovo lasciato vuoto non crea una nota; svuotare una nota esistente la conserva. -https://play.google.com/store/apps/details?id=bbnss.test.purenote&pcampaignid=web_share +I contatori riguardano tutto il testo, inclusa la prima riga: -OR +| Contatore | Convenzione | +| --- | --- | +| Parole | Sequenze separate da spazi o altro whitespace | +| Caratteri | Punti di codice Unicode, inclusi spazi e ritorni a capo | +| ≈ Token | `ceil(caratteri / 4)`, una stima; non usa un tokenizer specifico | -## APK Android -https://github.com/bbnss/PureNote/releases/tag/APK +## Avvio e verifiche su desktop + +Python 3.11 è la versione usata per sviluppo e CI. + +```bash +python3.11 -m venv .venv +.venv/bin/python -m pip install -r requirements-dev.txt +.venv/bin/python main.py +.venv/bin/python -m pytest -q +.venv/bin/ruff check . +.venv/bin/python tests/ui_smoke.py +``` + +Su desktop la condivisione copia il testo negli appunti; backup e ripristino usano +un selettore file Kivy. Il database predefinito è `notes.db` accanto a `main.py`. +Per provare l’app senza toccare le note locali: + +```bash +PURENOTE_DB=/tmp/purenote-demo.db .venv/bin/python main.py +``` + +La prova UI usa un database temporaneo, esercita il ciclo reale di Kivy e genera +anteprime in `/tmp/purenote-preview`. In CI gira con Xvfb. La simulazione degli +inset verifica il layout ridotto: non sostituisce una prova della tastiera Android. +La CI controlla anche densità Android 2,625 e caratteri al 200%, con verifiche +su testo, barra superiore e contatori per evitare sovrapposizioni. +Su Android gli spazi usano la densità del display e i caratteri la conversione +nativa `TypedValue.applyDimension`, che rispetta anche la scala non lineare +da Android 14. Il font non viene moltiplicato due volte per la preferenza utente. + +## APK su GitHub + +Il workflow **Build PureNote APK** esegue test della logica, lint e prova UI, poi +compila con Buildozer su Ubuntu e verifica firma e assenza di permessi nell’APK. +Parte su push, pull request oppure manualmente +in **Actions → Build PureNote APK → Run workflow**. Scarica `purenote-debug-apk` +negli artifact della run; sono disponibili anche le anteprime dell’interfaccia. +I log si consultano direttamente nella run, senza artifact diagnostici grezzi. +La compilazione predefinita è ARM64 per telefono. Per un emulatore Intel/AMD, +seleziona `x86_64` nel campo `architecture` della compilazione manuale. + +La toolchain è fissata in `requirements-build.txt` e `buildozer.spec`: +Buildozer 1.6.0, Kivy 2.3.1, python-for-android v2026.05.09 (commit esatto), +SDK 36, NDK 28c e Android minimo 7.0 (API 24). Gli esiti della compilazione sono +disponibili nelle run del workflow; i test locali non sostituiscono la build +Android e la prova su dispositivo. + +`constraints-android.txt` mantiene lo stesso vincolo sulle dipendenze sia nel +risolutore Android sia nell’installazione dei moduli Python. + +La cache riusa SDK, NDK e compilazioni parziali. Per aggiornamenti debug affidabili, +imposta `ANDROID_DEBUG_KEYSTORE_B64`: il workflow firma esplicitamente l’APK con +quella chiave dopo la compilazione e controlla che il certificato corrisponda. +La chiave usa alias `androiddebugkey` e password debug standard `android`. +Senza secret resta la firma temporanea generata da Gradle, che può cambiare tra run. + +Le precedenti build debug 3.0 non hanno conservato una firma stabile. Prima di +sostituirle, esporta un backup JSON, reinstalla e ripristina il backup. Conserva +anche una copia locale della nuova chiave debug, fuori dai file versionati. + +L’AAB viene preparato dal workflow separato di release: vedi [RELEASE.md](RELEASE.md). +Il repository non viene pubblicato o inviato su GitHub dall’app. + +## Codice e compatibilità dei dati + +| Modulo | Responsabilità | +| --- | --- | +| `main.py`, `purenote_ui.kv`, `theme.py` | Navigazione, widget e stile | +| `editor.py` | Sessione, autosalvataggio e gestione degli errori | +| `db.py` | SQLite, migrazioni transazionali e ultima nota aperta | +| `backup.py`, `stats.py` | Formato portabile e statistiche offline | +| `documents.py`, `android_window.py` | Integrazione Android e tastiera | + +Al primo avvio con il vecchio database, PureNote crea una copia SQLite +`notes.db.pre-v3.bak` prima della migrazione. Conserva testo, date, cestino, tag e +stato delle vecchie note fissate; tag e pin non compaiono più nell’interfaccia. +I vecchi titoli separati vengono inseriti nel testo solo se non sono già presenti +nella prima riga. Il Markdown resta testo normale. + +Il backup ha questo contenitore, con record delle note comprensivi di +`title`, `body`, `tags`, `pinned`, `created_at`, `updated_at`, `trashed`: + +```json +{"app": "PureNote", "version": 1, "notes": []} +``` + +Gli ID locali non vengono esportati. Il file è UTF-8, con limite di 32 MiB. +L’importazione valida tutte le note prima di scrivere, aggiunge copie con nuovi ID +e conserva le note esistenti. Ripristinare due volte lo stesso file crea duplicati. +I vecchi backup `.db` rimangono copie di sicurezza SQLite e non sono accettati dal +nuovo importatore JSON. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c8bbd75 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,70 @@ +# Release PureNote + +Il workflow **Build PureNote Release AAB** compila APK e AAB release per +`arm64-v8a` e `armeabi-v7a`, dopo test, lint e verifiche dell'interfaccia. +I pacchetti sono preparati per la firma locale: il runner non riceve keystore o +password di produzione. Nessuna pubblicazione su GitHub Releases o Play è automatica. + +La versione resta **3.0.2**, con `android.numeric_version = 10219`, successivo +alla build 10218 provata su dispositivo. Questa revisione aggiorna l'icona; +il vettoriale e i livelli adattivi sono descritti in [artwork/README.md](artwork/README.md). +L'application ID rimane `bbnss.test.purenote` per preservare aggiornamenti e note. + +## Compilazione + +Avvia **Actions → Build PureNote Release AAB → Run workflow** dal ramo desiderato. +Il workflow parte anche sui tag `v*`. Scarica entrambi gli artifact: + +- `purenote-release-apk-for-local-signing` +- `purenote-release-aab-for-local-signing` + +Questi file non sono ancora ufficiali: vanno firmati e verificati prima di distribuirli. +I log diagnostici grezzi non vengono caricati come artifact. + +La toolchain usa il commit p4a fissato in `buildozer.spec`. L'action condivisa +applica soltanto `venv --clear` all'ambiente pip temporaneo di ciascuna ABI: +senza pulizia, la ricreazione dello stesso venv per la seconda ABI può mescolare +file di versioni pip diverse e interrompere la compilazione. Le ricette native +compilate restano riutilizzabili dalla cache. + +## Firma locale e certificati + +Usa esclusivamente le chiavi esistenti e inserisci la password nel terminale. +Non inviare chiavi o password a GitHub e non generare una chiave sostitutiva. +Le APK debug di prova non costituiscono il rilascio ufficiale. + +L'APK per GitHub deve avere lo stesso certificato di firma dell'app distribuita +da Play. La firma è stata confermata sia tramite le API Play sia sull'APK storico +`PureNote_F.apk`; l'impronta SHA-256 pubblica è: + +```text +9D:61:85:C8:C5:CA:F9:B9:3A:1A:D1:5F:E2:52:E6:7A:08:DD:D7:D1:2B:DF:C0:CB:AD:66:80:BC:31:81:5A:D0 +``` + +Confrontala con il certificato del keystore locale prima di firmare. Con +Play App Signing la chiave di upload può essere diversa: per l'AAB occorre +la chiave già accettata da Google Play. Se manca la chiave privata corrispondente +alla firma dell'app, un APK firmato con la sola chiave di upload non può sostituire +l'APK dello Store: bisogna usare un APK firmato fornito da Play. + +Dopo zipalign e firma con gli strumenti Android, verifica l'APK: + +```sh +python tests/verify_apk.py --release --expected-sha256 "$PLAY_APP_CERT_SHA256" --sdk "$ANDROID_HOME" percorso/PureNote.apk +``` + +Il controllo rifiuta firme diverse, APK debuggable e permessi Android; distingue +il certificato dell'app dall'eventuale source stamp di Google. + +Verifica anche firma e integrità del bundle: + +```sh +python tests/verify_signing.py --expected-sha256 "$PLAY_UPLOAD_CERT_SHA256" percorso/PureNote.aab +java -jar bundletool.jar validate --bundle=percorso/PureNote.aab +``` + +Keystore, credenziali, database e pacchetti compilati sono ignorati da Git e +esclusi dai sorgenti confezionati da Buildozer. Allega solo l'APK ufficiale alla +release GitHub; conserva l'AAB firmato per il successivo caricamento su Play. +Prima della produzione completa la prova dell'APK su dispositivo e i controlli +richiesti dalla Play Console. diff --git a/android_window.py b/android_window.py new file mode 100644 index 0000000..e5ae83b --- /dev/null +++ b/android_window.py @@ -0,0 +1,55 @@ +"""Insets for system bars and the IME on Android's SDL2 window.""" + +from kivy.clock import Clock, mainthread + + +class AndroidWindow: + def __init__(self, on_insets): + from android.runnable import run_on_ui_thread + from jnius import autoclass + + self.on_insets = on_insets + self.activity = autoclass("org.kivy.android.PythonActivity").mActivity + self.api = autoclass("android.os.Build$VERSION").SDK_INT + self.types = autoclass("android.view.WindowInsets$Type") if self.api >= 30 else None + + @run_on_ui_thread + def configure(): + window = self.activity.getWindow() + # On modern Android we handle the keyboard insets ourselves. Kivy's + # SDL2 resize mode does not report IME height reliably. + window.setSoftInputMode(48 if self.api >= 30 else 16) + if self.api >= 30: + window.setDecorFitsSystemWindows(False) + controller = window.getInsetsController() + if controller: + controller.setSystemBarsAppearance(0, 8 | 16) + window.setStatusBarColor(0xFF101914 - 2**32) + window.setNavigationBarColor(0xFF101914 - 2**32) + self._read() + + self._read_on_ui = run_on_ui_thread(self._read) + configure() + self._event = Clock.schedule_interval(lambda _dt: self._read_on_ui(), 0.25) + + def _read(self): + view = self.activity.getWindow().getDecorView() + insets = view.getRootWindowInsets() + if insets is None: + return + if self.types: + edges = insets.getInsets( + self.types.systemBars() | self.types.displayCutout() | self.types.ime() + ) + self._deliver(edges.left, edges.top, edges.right, edges.bottom) + else: + # Legacy Android fits system bars and resizes the native surface + # for the IME. Adding the same insets again would pad twice. + self._deliver(0, 0, 0, 0) + + @mainthread + def _deliver(self, left, top, right, bottom): + self.on_insets(left, top, right, bottom) + + def close(self): + self._event.cancel() diff --git a/artwork/README.md b/artwork/README.md new file mode 100644 index 0000000..11f4c83 --- /dev/null +++ b/artwork/README.md @@ -0,0 +1,24 @@ +# Icona PureNote + +`icon.svg` è il disegno vettoriale approvato: foglio e cursore verde salvia +(`#A3DCB0`) su fondo scuro (`#0C120F`), senza font, immagini incorporate o +risorse esterne. + +`../logo.png` è la versione 1024 × 1024 per Kivy, icona Android tradizionale +e schermata iniziale. Buildozer richiede PNG per questi ingressi. + +L'icona adattiva Android usa `icon-foreground.png` e `icon-background.png`. +Il primo livello ha sfondo trasparente e simbolo ridotto al 90% per rispettare +l'area sicura delle maschere del launcher; il secondo è interamente opaco. +I due SVG corrispondenti conservano anche questi livelli in forma vettoriale. + +Per rigenerare i PNG con ImageMagick 7 e librsvg: + +```sh +magick -background none artwork/icon.svg -resize 1024x1024 -strip PNG32:logo.png +magick -background none artwork/icon-foreground.svg -resize 512x512 -strip PNG32:artwork/icon-foreground.png +magick -background none artwork/icon-background.svg -resize 512x512 -strip PNG32:artwork/icon-background.png +``` + +Eseguire dalla radice del repository. `artwork/` è esclusa dai file Python +confezionati nell'app; Buildozer legge i livelli direttamente durante la build. diff --git a/artwork/icon-background.png b/artwork/icon-background.png new file mode 100644 index 0000000..4db4375 Binary files /dev/null and b/artwork/icon-background.png differ diff --git a/artwork/icon-background.svg b/artwork/icon-background.svg new file mode 100644 index 0000000..9d479d3 --- /dev/null +++ b/artwork/icon-background.svg @@ -0,0 +1 @@ + diff --git a/artwork/icon-foreground.png b/artwork/icon-foreground.png new file mode 100644 index 0000000..0a4d668 Binary files /dev/null and b/artwork/icon-foreground.png differ diff --git a/artwork/icon-foreground.svg b/artwork/icon-foreground.svg new file mode 100644 index 0000000..14b5534 --- /dev/null +++ b/artwork/icon-foreground.svg @@ -0,0 +1,10 @@ + + PureNote + Un foglio verde salvia con angolo ripiegato, due righe di testo e un cursore su fondo verde scuro. + + + + + + + \ No newline at end of file diff --git a/artwork/icon.svg b/artwork/icon.svg new file mode 100644 index 0000000..e9c2df2 --- /dev/null +++ b/artwork/icon.svg @@ -0,0 +1,11 @@ + + PureNote + Un foglio verde salvia con angolo ripiegato, due righe di testo e un cursore su fondo verde scuro. + + + + + + + + diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..8d1ad5f --- /dev/null +++ b/backup.py @@ -0,0 +1,72 @@ +"""Portable UTF-8 backups. Validate every record before touching the store.""" + +import json +from dataclasses import asdict + +from db import NoteStore, title_from_text + +MAX_BACKUP_BYTES = 32 * 1024 * 1024 +FORMAT_VERSION = 1 + + +def export_backup(store: NoteStore) -> bytes: + notes = [ + {key: value for key, value in asdict(note).items() if key != "id"} + for note in store.all_notes() + ] + data = json.dumps( + {"app": "PureNote", "version": FORMAT_VERSION, "notes": notes}, ensure_ascii=False, indent=2 + ).encode("utf-8") + if len(data) > MAX_BACKUP_BYTES: + raise ValueError("Il backup supera il limite di 32 MB.") + return data + + +def read_backup(data: bytes) -> list[dict]: + if len(data) > MAX_BACKUP_BYTES: + raise ValueError("Il backup supera il limite di 32 MB.") + try: + document = json.loads(data.decode("utf-8-sig")) + except (UnicodeError, ValueError, RecursionError) as error: + raise ValueError("Il file non è un backup JSON valido.") from error + if ( + not isinstance(document, dict) + or document.get("app") != "PureNote" + or type(document.get("version")) is not int + or document["version"] != FORMAT_VERSION + or not isinstance(document.get("notes"), list) + ): + raise ValueError("Formato o versione del backup non supportati.") + notes = [] + for record in document["notes"]: + if not isinstance(record, dict): + raise ValueError("Il backup contiene una nota non valida.") + for key in ("title", "body", "tags"): + if not isinstance(record.get(key), str): + raise ValueError(f"Campo non valido nel backup: {key}.") + try: + record[key].encode("utf-8") + except UnicodeError as error: + raise ValueError(f"Testo Unicode non valido: {key}.") from error + for key in ("created_at", "updated_at", "pinned", "trashed"): + value = record.get(key) + if type(value) is not int or not 0 <= value <= 2**63 - 1: + raise ValueError(f"Campo non valido nel backup: {key}.") + if key in ("pinned", "trashed") and value not in (0, 1): + raise ValueError(f"Campo non valido nel backup: {key}.") + notes.append( + { + key: record[key] + for key in ( + "title", + "body", + "tags", + "pinned", + "created_at", + "updated_at", + "trashed", + ) + } + ) + notes[-1]["title"] = title_from_text(record["body"]) + return notes diff --git a/buildozer.spec b/buildozer.spec new file mode 100644 index 0000000..1133a91 --- /dev/null +++ b/buildozer.spec @@ -0,0 +1,44 @@ +[app] +title = PureNote +package.name = purenote +# Keep this ID so existing installations retain their private notes. +package.domain = bbnss.test +source.dir = . +source.include_exts = py,png,jpg,ttf,kv +source.exclude_exts = bak,orig +source.exclude_dirs = .venv,.build-venv,.git,.github,tests,images,artwork,bin,secrets,__pycache__,PureNote_export,.pytest_cache,.ruff_cache +version = 3.0.2 +android.numeric_version = 10219 +# Avoid a transitive Android wheel that p4a's pure-Python install stage rejects. +requirements = python3,kivy==2.3.1,sqlite3,pyjnius,charset_normalizer==3.4.2 +presplash.filename = %(source.dir)s/logo.png +icon.filename = %(source.dir)s/logo.png +icon.adaptive_foreground.filename = %(source.dir)s/artwork/icon-foreground.png +icon.adaptive_background.filename = %(source.dir)s/artwork/icon-background.png +android.presplash_color = #0C120F +orientation = portrait +fullscreen = 0 + +android.accept_sdk_license = True +# Notes are private. File access is granted by the system document picker. +# Omit android.permissions entirely: an empty value becomes android.permission. +android.api = 36 +android.minapi = 24 +android.ndk = 28c +android.archs = arm64-v8a +android.allow_backup = False +android.release_artifact = aab +android.debug_artifact = apk +p4a.bootstrap = sdl2 +# Official v2026.05.09 release; don't silently pick up recipe changes. +p4a.branch = master +p4a.commit = 58d21141f17c889bf8585f5665921d72028f8831 +# CI supplies the same pinned checkout with disposable pip environments fixed. +p4a.source_dir = + +[app@release-apk] +android.release_artifact = apk + +[buildozer] +log_level = 2 +warn_on_root = 1 diff --git a/constraints-android.txt b/constraints-android.txt new file mode 100644 index 0000000..16c6c5e --- /dev/null +++ b/constraints-android.txt @@ -0,0 +1,3 @@ +# p4a's resolver strips requirement versions before its cross-platform dry-run. +# This release provides a universal Python wheel instead of an Android extension. +charset-normalizer==3.4.2 diff --git a/db.py b/db.py new file mode 100644 index 0000000..63a8326 --- /dev/null +++ b/db.py @@ -0,0 +1,196 @@ +"""Local notes, transactional migrations, and remembered editor state.""" + +from __future__ import annotations + +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path + +SCHEMA_VERSION = 3 + + +def title_from_text(text: str) -> str: + """The first nonblank line is the title; the source text stays untouched.""" + return next((line.strip()[:80] for line in text.splitlines() if line.strip()), "Senza titolo") + + +def merge_legacy_title(title: str, body: str) -> str: + """Keep old explicit titles without duplicating generated/Markdown titles.""" + title = title.strip() + first = next((line.strip() for line in body.splitlines() if line.strip()), "") + comparable = first.lstrip("# ").strip() + if not title or title == "Senza titolo" or title in (first, comparable, comparable[:60]): + return body + return f"{title}\n\n{body}" if body else title + + +@dataclass(frozen=True) +class Note: + id: int + title: str + body: str + tags: str + pinned: int + created_at: int + updated_at: int + trashed: int + + +class NoteStore: + def __init__(self, path: str | Path): + self.path = str(path) + self.conn = sqlite3.connect(self.path, timeout=5) + self.conn.row_factory = sqlite3.Row + self.conn.create_function("casefold", 1, str.casefold, deterministic=True) + try: + self._migrate() + except Exception: + self.conn.close() + raise + + def _columns(self, table: str) -> set[str]: + return {row[1] for row in self.conn.execute(f"PRAGMA table_info({table})")} + + def _migrate(self) -> None: + version = self.conn.execute("PRAGMA user_version").fetchone()[0] + if version > SCHEMA_VERSION: + raise ValueError("Database creato da una versione più recente di PureNote.") + if version == SCHEMA_VERSION: + return + if self._columns("notes") or self._columns("notes_legacy"): + # The backup API includes committed WAL pages, unlike a file copy. + backup_path = self.path + ".pre-v3.bak" + if Path(backup_path).exists(): + backup_path = self.path + f".pre-v3-{time.time_ns()}.bak" + backup = sqlite3.connect(backup_path) + try: + self.conn.backup(backup) + finally: + backup.close() + + self.conn.execute("BEGIN IMMEDIATE") + try: + columns = self._columns("notes") + if "note" in columns and "id" not in columns: + self.conn.execute("ALTER TABLE notes RENAME TO notes_legacy") + self.conn.execute("""CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + pinned INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + trashed INTEGER NOT NULL DEFAULT 0 + )""") + if "trashed" not in self._columns("notes"): + self.conn.execute("ALTER TABLE notes ADD COLUMN trashed INTEGER NOT NULL DEFAULT 0") + for row in self.conn.execute("SELECT id, title, body FROM notes").fetchall(): + body = merge_legacy_title(row["title"], row["body"]) + self.conn.execute( + "UPDATE notes SET title=?, body=? WHERE id=?", + (title_from_text(body), body, row["id"]), + ) + if "note" in self._columns("notes_legacy"): + now = int(time.time()) + for row in self.conn.execute("SELECT note FROM notes_legacy").fetchall(): + body = row[0] or "" + self.conn.execute( + "INSERT INTO notes(title,body,created_at,updated_at) VALUES(?,?,?,?)", + (title_from_text(body), body, now, now), + ) + self.conn.execute("DROP TABLE notes_legacy") + self.conn.execute( + "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + self.conn.execute( + "CREATE INDEX IF NOT EXISTS notes_recent ON notes(trashed, updated_at DESC, id DESC)" + ) + self.conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + self.conn.commit() + except Exception: + self.conn.rollback() + raise + + def get(self, note_id: int) -> Note | None: + row = self.conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() + return Note(**dict(row)) if row else None + + def list_notes(self, query: str = "", *, trashed: bool = False) -> list[Note]: + # instr treats %, _ and backslashes as literal search characters. + rows = self.conn.execute( + "SELECT * FROM notes WHERE trashed=? AND " + "(?='' OR instr(casefold(body), casefold(?)) > 0) " + "ORDER BY updated_at DESC, id DESC", + (int(trashed), query, query), + ) + return [Note(**dict(row)) for row in rows] + + def save_note(self, body: str, note_id: int | None = None) -> int: + now = int(time.time()) + with self.conn: + if note_id is None: + cursor = self.conn.execute( + "INSERT INTO notes(title,body,created_at,updated_at) VALUES(?,?,?,?)", + (title_from_text(body), body, now, now), + ) + note_id = cursor.lastrowid + else: + cursor = self.conn.execute( + "UPDATE notes SET title=?,body=?,updated_at=? WHERE id=? AND trashed=0", + (title_from_text(body), body, now, note_id), + ) + if cursor.rowcount != 1: + raise ValueError("La nota non è disponibile.") + self._remember(note_id) + return note_id + + def _remember(self, note_id: int | None) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO settings(key,value) VALUES('last_note',?)", (str(note_id or 0),) + ) + + def remember(self, note_id: int | None) -> None: + with self.conn: + self._remember(note_id) + + def last_note(self) -> Note | None: + row = self.conn.execute("SELECT value FROM settings WHERE key='last_note'").fetchone() + note = self.get(int(row[0])) if row else None + if note and not note.trashed: + return note + row = self.conn.execute( + "SELECT * FROM notes WHERE trashed=0 ORDER BY updated_at DESC,id DESC LIMIT 1" + ).fetchone() + return Note(**dict(row)) if row else None + + def set_trashed(self, note_id: int, trashed: bool) -> None: + with self.conn: + self.conn.execute( + "UPDATE notes SET trashed=?,updated_at=? WHERE id=?", + (int(trashed), int(time.time()), note_id), + ) + + def delete_forever(self, note_id: int | None = None) -> None: + with self.conn: + if note_id is None: + self.conn.execute("DELETE FROM notes WHERE trashed=1") + else: + self.conn.execute("DELETE FROM notes WHERE id=? AND trashed=1", (note_id,)) + + def all_notes(self) -> list[Note]: + return [Note(**dict(row)) for row in self.conn.execute("SELECT * FROM notes ORDER BY id")] + + def import_notes(self, notes: list[dict]) -> int: + """Insert an already validated backup atomically, assigning fresh IDs.""" + with self.conn: + self.conn.executemany( + "INSERT INTO notes(title,body,tags,pinned,created_at,updated_at,trashed) " + "VALUES(:title,:body,:tags,:pinned,:created_at,:updated_at,:trashed)", + notes, + ) + return len(notes) + + def close(self) -> None: + self.conn.close() diff --git a/documents.py b/documents.py new file mode 100644 index 0000000..55f1b4e --- /dev/null +++ b/documents.py @@ -0,0 +1,135 @@ +"""Android document picker and sharing without broad storage permissions. + +Provider I/O runs on a worker; callbacks return to the Kivy thread. +""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from threading import Thread + +from kivy.clock import mainthread + +from backup import MAX_BACKUP_BYTES + + +class AndroidDocuments: + WRITE_BACKUP = 801 + READ_BACKUP = 802 + + def __init__(self, on_error: Callable[[str], None]): + from android import activity + from jnius import autoclass + + self.activity_events = activity + self.activity = autoclass("org.kivy.android.PythonActivity").mActivity + self.Intent = autoclass("android.content.Intent") + self.on_error = on_error + self._pending: tuple | None = None + activity.bind(on_activity_result=self._on_result) + + def export(self, name: str, data: bytes, on_saved: Callable[[], None]) -> None: + self._pick(self.WRITE_BACKUP, name, data, on_saved) + + def restore(self, on_read: Callable[[bytes], None]) -> None: + self._pick(self.READ_BACKUP, "", None, on_read) + + def _pick(self, code: int, name: str, data: bytes | None, callback: Callable) -> None: + from android.runnable import run_on_ui_thread + + if self._pending is not None: + self.on_error("Completa prima la selezione del file già aperta.") + return + self._pending = (code, data, callback) + + @run_on_ui_thread + def launch(): + try: + action = ( + self.Intent.ACTION_CREATE_DOCUMENT + if code == self.WRITE_BACKUP + else self.Intent.ACTION_OPEN_DOCUMENT + ) + intent = self.Intent(action) + intent.addCategory(self.Intent.CATEGORY_OPENABLE) + # Some document providers label JSON files as plain text. + intent.setType("application/json" if code == self.WRITE_BACKUP else "*/*") + if name: + intent.putExtra(self.Intent.EXTRA_TITLE, name) + self.activity.startActivityForResult(intent, code) + except Exception as error: + self._failed(str(error)) + + launch() + + @mainthread + def _on_result(self, request_code, result_code, intent): + if self._pending is None or self._pending[0] != request_code: + return + if result_code != -1 or intent is None or intent.getData() is None: + self._pending = None + return + code, data, callback = self._pending + uri = intent.getData() + + def transfer(): + try: + resolver = self.activity.getContentResolver() + mode = "w" if code == self.WRITE_BACKUP else "r" + descriptor = resolver.openFileDescriptor(uri, mode) + if descriptor is None: + raise OSError("Il documento non è accessibile.") + # detachFd transfers ownership to Python; fdopen closes it on error too. + fd = descriptor.detachFd() + descriptor.close() + with os.fdopen(fd, "wb" if data is not None else "rb") as stream: + if data is not None: + stream.write(data) + stream.flush() + result = None + else: + result = stream.read(MAX_BACKUP_BYTES + 1) + self._completed(callback, result) + except Exception as error: + self._failed(str(error)) + + Thread(target=transfer, name="purenote-document", daemon=True).start() + + @mainthread + def _completed(self, callback, result): + self._pending = None + if result is None: + callback() + else: + callback(result) + + @mainthread + def _failed(self, message): + self._pending = None + self.on_error(f"Operazione sul file non riuscita.\n{message}") + + def share(self, text: str) -> None: + from android.runnable import run_on_ui_thread + from jnius import autoclass, cast + + @run_on_ui_thread + def launch(): + try: + String = autoclass("java.lang.String") + intent = self.Intent(self.Intent.ACTION_SEND) + intent.setType("text/plain") + intent.putExtra( + self.Intent.EXTRA_TEXT, cast("java.lang.CharSequence", String(text)) + ) + chooser = self.Intent.createChooser( + intent, cast("java.lang.CharSequence", String("Condividi nota")) + ) + self.activity.startActivity(chooser) + except Exception as error: + self._failed(str(error)) + + launch() + + def close(self) -> None: + self.activity_events.unbind(on_activity_result=self._on_result) diff --git a/editor.py b/editor.py new file mode 100644 index 0000000..c40e1da --- /dev/null +++ b/editor.py @@ -0,0 +1,86 @@ +"""Editor session independent of Kivy; scheduling is injected for testing.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Callable +from typing import Protocol + +from db import NoteStore + + +class ScheduledCall(Protocol): + def cancel(self) -> None: ... + + +Schedule = Callable[[Callable, float], ScheduledCall] + + +class EditorSession: + DEBOUNCE = 0.3 + MAX_WAIT = 2.0 + + def __init__( + self, store: NoteStore, schedule: Schedule, on_change: Callable[[], None] = lambda: None + ): + self.store = store + self.schedule = schedule + self.on_change = on_change + self.note_id: int | None = None + self.text = "" + self.dirty = False + self.error = "" + self._debounce: ScheduledCall | None = None + self._deadline: ScheduledCall | None = None + + def open(self, note_id: int | None) -> bool: + if not self.flush(): + return False + note = self.store.get(note_id) if note_id else None + if note_id and (note is None or note.trashed): + raise ValueError("La nota non è disponibile.") + self.store.remember(note_id) + self.note_id = note_id + self.text = note.body if note else "" + self.error = "" + self.on_change() + return True + + def edit(self, text: str) -> None: + if text == self.text: + return + self.text = text + self.dirty = True + if self._debounce: + self._debounce.cancel() + self._debounce = self.schedule(self.flush, self.DEBOUNCE) + if self._deadline is None: + self._deadline = self.schedule(self.flush, self.MAX_WAIT) + self.on_change() + + def _cancel_pending(self) -> None: + for event in (self._debounce, self._deadline): + if event: + event.cancel() + self._debounce = self._deadline = None + + def flush(self, *_args) -> bool: + self._cancel_pending() + if not self.dirty: + return True + try: + # A blank draft never creates an empty database row. + if self.note_id is not None or self.text.strip(): + self.note_id = self.store.save_note(self.text, self.note_id) + except (sqlite3.Error, OSError, ValueError) as error: + self.error = str(error) + self._deadline = self.schedule(self.flush, self.MAX_WAIT) + self.on_change() + return False + self.dirty = False + self.error = "" + self.on_change() + return True + + def dispose(self) -> None: + self._cancel_pending() diff --git a/images/purenote-editor.png b/images/purenote-editor.png new file mode 100644 index 0000000..b36ce43 Binary files /dev/null and b/images/purenote-editor.png differ diff --git a/images/purenote-list.png b/images/purenote-list.png new file mode 100644 index 0000000..1b4c6f7 Binary files /dev/null and b/images/purenote-list.png differ diff --git a/logo.png b/logo.png index 3c77dab..50e0394 100644 Binary files a/logo.png and b/logo.png differ diff --git a/main.py b/main.py index c20ddea..3d1ab9f 100644 --- a/main.py +++ b/main.py @@ -1,174 +1,631 @@ +"""PureNote: one quiet place to write, with local, automatic persistence.""" + +from __future__ import annotations + +import os import sqlite3 +import time +from collections.abc import Callable +from pathlib import Path + from kivy.app import App -from kivy.uix.button import Button -from kivy.uix.textinput import TextInput +from kivy.clock import Clock +from kivy.core.clipboard import Clipboard +from kivy.core.window import Window +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.metrics import dp +from kivy.properties import BooleanProperty, ListProperty, NumericProperty, StringProperty +from kivy.uix.behaviors import ButtonBehavior from kivy.uix.boxlayout import BoxLayout -from kivy.uix.recycleview import RecycleView -from kivy.uix.popup import Popup -from kivy.uix.recycleview.views import RecycleDataViewBehavior +from kivy.uix.filechooser import FileChooserListView from kivy.uix.label import Label -from kivy.properties import BooleanProperty, StringProperty -from kivy.lang import Builder -from kivy.uix.image import Image - - -# Setup the database -conn = sqlite3.connect('notes.db') -c = conn.cursor() -c.execute('''CREATE TABLE IF NOT EXISTS notes - (note text)''') - -# Define RecycleView in KV language -Builder.load_string(''' -: - orientation: 'horizontal' - Label: - text: root.display_text - Button: - text: 'Delete' - on_press: root.delete_note() - -: - # Draw a background to indicate selection - canvas.before: - Color: - rgba: (.0, .9, .1, .3) if self.selected else (0, 0, 0, 1) - Rectangle: - pos: self.pos - size: self.size - -: - viewclass: 'SelectableBoxLayout' - RecycleBoxLayout: - default_size: None, dp(56) - default_size_hint: 1, None - size_hint_y: None - height: self.minimum_height - orientation: 'vertical' -''') - -class SelectableBoxLayout(RecycleDataViewBehavior, BoxLayout): - """ Add selection support to the BoxLayout """ - text = StringProperty() - display_text = StringProperty() - selected = BooleanProperty(False) - - def on_text(self, instance, value): - self.display_text = value[:15] + ' (' + str(len(value)) + ')' - - def on_touch_down(self, touch): - if super(SelectableBoxLayout, self).on_touch_down(touch): - return True - if self.collide_point(*touch.pos): - return self.select_note(touch) +from kivy.uix.popup import Popup +from kivy.uix.recycleview import RecycleView # noqa: F401 - registers the KV class +from kivy.uix.screenmanager import Screen, ScreenManager +from kivy.uix.scrollview import ScrollView +from kivy.uix.textinput import TextInput +from kivy.utils import platform - def select_note(self, touch): - if self.selected: - return False - else: - # Deselect the previously selected note, if there is one - if App.get_running_app().selected_note is not None: - App.get_running_app().selected_note.selected = False - - # Select the current note - self.selected = True - App.get_running_app().selected_note = self - App.get_running_app().note_input.text = self.text - App.get_running_app().note_popup.dismiss() - return True +import theme +from backup import MAX_BACKUP_BYTES, export_backup, read_backup +from db import NoteStore +from editor import EditorSession +from stats import count_text + +BASE_DIR = Path(__file__).resolve().parent + + +class QuietButton(ButtonBehavior, Label): + primary = BooleanProperty(False) + danger = BooleanProperty(False) + + +class IconButton(QuietButton): + """Small canvas icons keep navigation independent of font glyph coverage.""" + icon = StringProperty("menu") - def delete_note(self): - def delete(btn): - c.execute("DELETE FROM notes WHERE note=?", (self.text,)) - conn.commit() - App.get_running_app().note_list.fetch_notes() - confirm_popup.dismiss() - confirm_popup = Popup(title='Confirm Delete') - confirm_layout = BoxLayout(orientation='vertical') - confirm_layout.add_widget(Label(text=f'Are you sure you want to delete "{self.text}"?')) - button_layout = BoxLayout(orientation='horizontal') - button_layout.add_widget(Button(text='Yes', on_press=delete)) - button_layout.add_widget(Button(text='Cancel', on_press=confirm_popup.dismiss)) - confirm_layout.add_widget(button_layout) - confirm_popup.content = confirm_layout - confirm_popup.open() +class NoteInput(TextInput): + pass -class RV(RecycleView): - def __init__(self, **kwargs): - super(RV, self).__init__(**kwargs) - self.fetch_notes() - def fetch_notes(self): - c.execute('SELECT * FROM notes') - self.data = [{'text': str(row[0])} for row in c.fetchall()] +class NoteRow(ButtonBehavior, BoxLayout): + note_id = NumericProperty(0) + title = StringProperty() + snippet = StringProperty() + date = StringProperty() + def on_release(self): + App.get_running_app().open_editor(self.note_id) -class NoteApp(App): - - def __init__(self, **kwargs): - super(NoteApp, self).__init__(**kwargs) - # Initialize the selected note to None - self.selected_note = None +class ListScreen(Screen): + query = StringProperty() + empty_message = StringProperty() + summary = StringProperty() + + +class EditorScreen(Screen): + counts = StringProperty("0 parole · 0 caratteri · ≈0 token") + status = StringProperty("Salvataggio automatico") + save_error = BooleanProperty(False) + words = StringProperty("0") + characters = StringProperty("0") + tokens = StringProperty("≈0") + loading = False + + def on_text_changed(self, text): + app = App.get_running_app() + if not self.loading and hasattr(app, "session"): + app.session.edit(text) + + +class RootManager(ScreenManager): + pass + + +class InsetPopup(Popup): + """Keep Kivy's modal centering inside the area above the Android keyboard.""" + + safe_center = ListProperty([0, 0]) + + def _align_center(self, *_): + if self._is_open: + self.center = self.safe_center + + +class PureNoteApp(App): + insets = ListProperty([0, 0, 0, 0]) def build(self): - # Main layout - box = BoxLayout(orientation='vertical') - - top_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=100) - top_layout.add_widget(Button(text='New Note', font_name='TT.ttf', on_press=self.new_note, size_hint_x=0.25, background_color=(0, 1, 0, 1), color=(1, 1, 0, 1))) - top_layout.add_widget(Button(text='List', font_name='TT.ttf', on_press=self.show_saved_notes, size_hint_x=0.25, background_color=(0, 0, 0, 1), color=(1, 0, 1, 1))) - top_layout.add_widget(Button(text='Delete', font_name='TT.ttf', on_press=self.delete_note, size_hint_x=0.25, background_color=(0, 1, 0, 1), color=(1, 1, 0, 1))) - top_layout.add_widget(Button(text='Save', font_name='TT.ttf', on_press=self.save_note, size_hint_x=0.25, background_color=(0, 0, 0, 1), color=(1, 0, 1, 1))) - - box.add_widget(top_layout) - - # Text input for new note - self.note_input = TextInput(font_name='GS.ttf', background_color=[1, 1, 1, 1]) - - box.add_widget(self.note_input) - - # Popup for saved notes - self.note_list = RV() - self.note_popup = Popup(title='Saved Notes', content=self.note_list) - - # Add banner image at the bottom - banner = Image(source='anelli.png', size_hint_y=None, height=100, allow_stretch=True, keep_ratio=False) - - box.add_widget(banner) - - return box - - def new_note(self, instance): - self.note_input.text = '' - - def save_note(self, instance): - c.execute("INSERT INTO notes VALUES (?)", (self.note_input.text,)) - conn.commit() - self.note_list.fetch_notes() - - def delete_note(self, instance): - def delete(btn): - c.execute("DELETE FROM notes WHERE note=?", (self.note_input.text,)) - conn.commit() - self.note_list.fetch_notes() - self.note_input.text = '' - confirm_popup.dismiss() - - confirm_popup = Popup(title='Confirm Delete') - confirm_layout = BoxLayout(orientation='vertical') - confirm_layout.add_widget(Label(text=f'Are you sure you want to delete "{self.note_input.text}"?')) - button_layout = BoxLayout(orientation='horizontal') - button_layout.add_widget(Button(text='Yes', on_press=delete)) - button_layout.add_widget(Button(text='Cancel', on_press=confirm_popup.dismiss)) - confirm_layout.add_widget(button_layout) - confirm_popup.content = confirm_layout - confirm_popup.open() - - def show_saved_notes(self, instance): - self.note_popup.open() - -if __name__ == '__main__': - NoteApp().run() + if platform == "android": + theme.configure_android_scaling() + self.title = "PureNote" + self.icon = str(BASE_DIR / "logo.png") + Window.clearcolor = theme.BG + Window.softinput_mode = "" + self.documents = None + self.android_window = None + self._popup: Popup | None = None + self._undo_id: int | None = None + self._undo_event = None + self._stats_trigger = Clock.create_trigger(self._update_stats, 0.06) + self._search_trigger = Clock.create_trigger(self.refresh_list, 0.12) + # Keep the old desktop path so upgrading finds the existing database. + directory = Path(self.user_data_dir) if platform == "android" else BASE_DIR + path = Path(os.environ.get("PURENOTE_DB", str(directory / "notes.db"))) + path.parent.mkdir(parents=True, exist_ok=True) + try: + self.store = NoteStore(path) + except (sqlite3.Error, OSError, ValueError) as error: + Logger.exception("PureNote: cannot open notes") + return self.message_label( + f"Impossibile aprire le note.\n\n{error}\n\n" + "Il database originale è stato conservato." + ) + self.session = EditorSession(self.store, Clock.schedule_once, self._session_changed) + Window.bind(on_keyboard=self._on_key, on_request_close=self._on_request_close) + return Builder.load_file(str(BASE_DIR / "purenote_ui.kv")) + + def on_start(self): + if not hasattr(self, "session"): + return + if platform == "android": + from android_window import AndroidWindow + from documents import AndroidDocuments + + self.documents = AndroidDocuments(self.show_message) + self.android_window = AndroidWindow(self._set_insets) + note = self.store.last_note() + self.open_editor(note.id if note else None) + + def _set_insets(self, left, top, right, bottom): + self.insets = [left, top, right, bottom] + + @property + def editor(self) -> EditorScreen: + return self.root.get_screen("editor") + + def _session_changed(self): + if not self.root: + return + editor = self.editor + editor.save_error = bool(self.session.error) + if self.session.error: + editor.status = "Salvataggio non riuscito · tocca per riprovare" + elif self.session.dirty: + editor.status = "Salvataggio…" + elif self.session.note_id: + editor.status = "Tutto salvato" + else: + editor.status = "Salvataggio automatico" + self._stats_trigger() + + def _update_stats(self, *_): + stats = count_text(self.session.text) + self.editor.words = str(stats.words) + self.editor.characters = str(stats.characters) + self.editor.tokens = f"≈{stats.estimated_tokens}" + self.editor.counts = ( + f"{stats.words} parole · {stats.characters} caratteri" + f" · ≈{stats.estimated_tokens} token" + ) + + def flush(self) -> bool: + if self.session.flush(): + return True + self.show_message( + "Il testo resta aperto. Non è stato possibile salvarlo.\n\n" + f"{self.session.error}\n\nPuoi riprovare o condividere il testo dal menu." + ) + return False + + def open_editor(self, note_id: int | None = None): + try: + if not self.session.open(note_id): + self.flush() + return + except (sqlite3.Error, OSError, ValueError) as error: + self.show_message(str(error)) + return + self.editor.loading = True + self.editor.ids.body.text = self.session.text + self.editor.ids.body.cursor = (0, 0) + self.editor.loading = False + self.root.current = "editor" + self._update_stats() + # Focus on new sheets only, so reading the last note doesn't open the IME. + self.editor.ids.body.focus = note_id is None + + def new_note(self): + self.open_editor() + + def go_list(self): + if not self.flush(): + return + self.editor.ids.body.focus = False + self.root.current = "list" + self.refresh_list() + + def set_query(self, query: str): + if not self.root: + return + self.root.get_screen("list").query = query + self._search_trigger() + + def refresh_list(self, *_): + screen = self.root.get_screen("list") + notes = self.store.list_notes(screen.query.strip()) + screen.summary = f"{len(notes)} " + ("nota" if len(notes) == 1 else "note") + screen.empty_message = ( + "Nessuna corrispondenza.\nProva un’altra ricerca." + if screen.query.strip() + else "Uno spazio per i tuoi pensieri.\nTocca + per la prima nota." + ) + screen.ids.notes.data = [ + { + "note_id": note.id, + "title": note.title, + "snippet": self._snippet(note.body), + "date": time.strftime("%d.%m.%Y · %H:%M", time.localtime(note.updated_at)), + } + for note in notes + ] + screen.ids.empty.opacity = 0 if notes else 1 + + @staticmethod + def _snippet(text: str) -> str: + lines = text.lstrip().splitlines() + return " ".join(" ".join(lines[1:]).split())[:140] + + def _on_key(self, _window, key, *_): + if key == 27 and self._popup is None and self.root.current == "editor": + if self.editor.ids.body.focus: + self.editor.ids.body.focus = False + else: + self.go_list() + return True + return False + + def _on_request_close(self, *_): + return hasattr(self, "session") and not self.flush() + + def on_pause(self): + if hasattr(self, "session"): + self.session.flush() + return True + + def on_resume(self): + if hasattr(self, "session"): + self.session.flush() + + def on_stop(self): + if hasattr(self, "session"): + if not self.session.flush(): + Logger.error("PureNote: stopped with unsaved changes: %s", self.session.error) + self.session.dispose() + self.store.close() + if self.documents: + self.documents.close() + if self.android_window: + self.android_window.close() + Window.unbind(on_keyboard=self._on_key, on_request_close=self._on_request_close) + + # Dialogs use the same typography and touch sizes as the writing surface. + @staticmethod + def message_label(text: str, *, scrollable=False) -> Label: + label = Label( + text=text, + font_name=theme.FONT_BODY, + font_size=theme.font(16), + color=theme.TEXT, + halign="left", + valign="middle", + ) + if scrollable: + label.size_hint_y = None + label.valign = "top" + label.bind( + width=lambda widget, width: setattr(widget, "text_size", (width, None)), + texture_size=lambda widget, size: setattr(widget, "height", size[1]), + ) + else: + label.bind(size=lambda widget, size: setattr(widget, "text_size", size)) + return label + + def _dialog(self, title: str, content, *, height=0.65, preferred_height=None) -> Popup: + if self._popup: + self._popup.dismiss() + popup = InsetPopup( + title=title, + content=content, + size_hint=(None, None), + background="", + background_color=theme.PANEL, + separator_color=theme.LINE, + title_font=theme.FONT_BODY, + title_color=theme.ACCENT, + title_size=theme.font(18), + title_align="left", + padding=dp(16), + ) + + def fit(*_): + left, top, right, bottom = self.insets + width = max(dp(160), Window.width - left - right - dp(32)) + available = max(dp(140), Window.height - top - bottom - dp(32)) + popup.width = min(dp(440), width) + popup.height = min(preferred_height or available * height, available) + popup.safe_center = [ + left + (Window.width - left - right) / 2, + bottom + (Window.height - top - bottom) / 2, + ] + popup.pos_hint = { + "center_x": popup.safe_center[0] / Window.width, + "center_y": popup.safe_center[1] / Window.height, + } + popup.center = popup.safe_center + + def dismissed(*_): + Window.unbind(size=fit) + self.unbind(insets=fit) + if self._popup is popup: + self._popup = None + + Window.bind(size=fit) + self.bind(insets=fit) + popup.bind(on_dismiss=dismissed) + popup.bind(on_open=fit) + fit() + self._popup = popup + popup.open() + return popup + + def _button(self, text: str, callback: Callable, *, danger=False, primary=False): + button = QuietButton(text=text, danger=danger, primary=primary) + button.bind(on_release=lambda *_: callback()) + return button + + def _actions(self, title: str, actions: list[tuple[str, Callable]], message=""): + box = BoxLayout(orientation="vertical", spacing=dp(12)) + if message: + scroll = ScrollView(do_scroll_x=False) + label = self.message_label(message, scrollable=True) + scroll.add_widget(label) + box.add_widget(scroll) + items = BoxLayout(orientation="vertical", spacing=dp(6), size_hint_y=None) + if actions: + scroll = ScrollView(do_scroll_x=False) + items.bind(minimum_height=items.setter("height")) + scroll.add_widget(items) + box.add_widget(scroll) + button_height = max(dp(48), theme.font(15) + dp(28)) + preferred = dp(96) + (button_height + dp(6)) * (len(actions) + 1) + popup = self._dialog( + title, box, preferred_height=max(preferred, dp(400)) if message else preferred + ) + for text, action in actions: + + def invoke(fn=action): + popup.dismiss() + fn() + + button = self._button(text, invoke) + button.halign = "left" + items.add_widget(button) + box.add_widget(self._button("Chiudi", popup.dismiss)) + + def show_message(self, message: str): + self._actions("PureNote", [], message) + + def confirm(self, title: str, message: str, callback: Callable, label="Conferma"): + box = BoxLayout(orientation="vertical", spacing=dp(12)) + scroll = ScrollView(do_scroll_x=False) + scroll.add_widget(self.message_label(message, scrollable=True)) + box.add_widget(scroll) + popup = self._dialog(title, box, preferred_height=dp(400)) + + def accept(): + popup.dismiss() + self._storage_action(callback) + + box.add_widget(self._button(label, accept, danger=label == "Elimina")) + box.add_widget(self._button("Annulla", popup.dismiss)) + + def _storage_action(self, action: Callable): + try: + action() + except (sqlite3.Error, OSError, ValueError) as error: + Logger.exception("PureNote: storage operation failed") + self.show_message(f"Operazione non riuscita.\n\n{error}") + + def open_menu(self): + self.editor.ids.body.focus = False + actions = [] + if self.root.current == "editor": + actions.extend( + [ + ("Nuova nota", self.new_note), + ("Condividi nota", self.share_note), + ("Sposta nel cestino", self.trash_note), + ] + ) + if self.session.error: + actions.append(("Riprova salvataggio", self.flush)) + if self._undo_id is not None: + actions.append(("Annulla ultima eliminazione", self.undo_trash)) + actions.extend( + [ + ("Cestino", self.open_trash), + ("Esporta backup", self.export_notes), + ("Ripristina backup", self.restore_notes), + ("Informazioni sui contatori", self.explain_counts), + ] + ) + self._actions("Opzioni nota" if self.root.current == "editor" else "Opzioni", actions) + + def explain_counts(self): + self.show_message( + "Parole: gruppi di testo separati da spazi.\n\n" + "Caratteri: punti di codice Unicode, inclusi spazi e ritorni a capo. " + "Alcune emoji sono composte da più caratteri.\n\n" + "≈ Token: stima pari ai caratteri divisi per 4, arrotondata per eccesso. " + "Il conteggio effettivo dipende dal modello." + ) + + def trash_note(self): + if not self.flush(): + return + note_id = self.session.note_id + if note_id is None: + self.go_list() + return + + def move(): + self.store.set_trashed(note_id, True) + self.session.open(None) + self._undo_id = note_id + self.go_list() + self.root.get_screen("list").ids.undo.disabled = False + if self._undo_event: + self._undo_event.cancel() + self._undo_event = Clock.schedule_once(self._expire_undo, 8) + + self._storage_action(move) + + def _expire_undo(self, *_): + self._undo_id = None + self.root.get_screen("list").ids.undo.disabled = True + + def undo_trash(self): + if self._undo_id is None: + return + + def restore(): + note_id = self._undo_id + self.store.set_trashed(note_id, False) + self._expire_undo() + self.open_editor(note_id) + + self._storage_action(restore) + + def open_trash(self): + if not self.flush(): + return + notes = self.store.list_notes(trashed=True) + actions = [(note.title, lambda nid=note.id: self._trash_actions(nid)) for note in notes] + if notes: + actions.append( + ( + "Svuota cestino…", + lambda: self.confirm( + "Svuota cestino", + "Eliminare definitivamente tutte le note del cestino?", + self._empty_trash, + "Elimina", + ), + ) + ) + self._actions("Cestino", actions, "" if notes else "Il cestino è vuoto.") + + def _trash_actions(self, note_id: int): + note = self.store.get(note_id) + if note is None: + return + self._actions( + "Nota nel cestino", + [ + ("Ripristina", lambda: self._storage_action(lambda: self._restore_note(note_id))), + ( + "Elimina definitivamente…", + lambda: self.confirm( + "Elimina nota", + "La nota verrà eliminata definitivamente.", + lambda: self._delete_note(note_id), + "Elimina", + ), + ), + ], + message=note.title, + ) + + def _restore_note(self, note_id): + self.store.set_trashed(note_id, False) + if self._undo_id == note_id: + self._expire_undo() + self.refresh_list() + self.open_editor(note_id) + + def _delete_note(self, note_id): + self.store.delete_forever(note_id) + if self._undo_id == note_id: + self._expire_undo() + self.open_trash() + + def _empty_trash(self): + self.store.delete_forever() + self._expire_undo() + self.open_trash() + + def share_note(self): + text = self.session.text + if not text.strip(): + self.show_message("La nota è vuota.") + elif self.documents: + self.documents.share(text) + else: + Clipboard.copy(text) + self.show_message("Nota copiata negli appunti.") + + # Snapshot exports and complete validation happen before any import writes. + def export_notes(self): + if not self.flush(): + return + + def export(): + data = export_backup(self.store) + name = time.strftime("purenote-%Y%m%d-%H%M%S.json") + if self.documents: + self.documents.export(name, data, lambda: self.show_message("Backup esportato.")) + else: + self._desktop_file_dialog(save_data=data, name=name) + + self._storage_action(export) + + def restore_notes(self): + if not self.flush(): + return + if self.documents: + self.documents.restore(self._preview_restore) + else: + self._desktop_file_dialog() + + def _preview_restore(self, data: bytes): + try: + notes = read_backup(data) + except ValueError as error: + self.show_message(str(error)) + return + if not notes: + self.show_message("Il backup non contiene note.") + return + + def restore(): + if not self.flush(): + return + count = self.store.import_notes(notes) + self.refresh_list() + self.show_message(f"Ripristinate {count} note.") + + self.confirm( + "Ripristina backup", + f"Aggiungere {len(notes)} note, incluso il cestino?\n\n" + "Le note presenti verranno conservate. Importare lo stesso backup " + "più volte crea copie aggiuntive.", + restore, + "Ripristina", + ) + + def _desktop_file_dialog(self, save_data: bytes | None = None, name=""): + box = BoxLayout(orientation="vertical", spacing=dp(8)) + chooser = FileChooserListView( + path=str(Path(self.store.path).resolve().parent), + filters=[] if save_data is not None else ["*.json"], + ) + box.add_widget(chooser) + filename = NoteInput(text=name, multiline=False, size_hint_y=None, height=dp(48)) + if save_data is not None: + box.add_widget(filename) + popup = self._dialog( + "Esporta backup" if save_data is not None else "Apri backup", box, height=0.9 + ) + + def choose(): + try: + if save_data is not None: + if not filename.text.strip() or Path(filename.text).name != filename.text: + raise ValueError("Inserisci un nome file senza cartelle.") + target = Path(chooser.path) / filename.text + # Never silently replace another backup. + with target.open("xb") as stream: + stream.write(save_data) + popup.dismiss() + self.show_message("Backup esportato.") + elif chooser.selection: + with Path(chooser.selection[0]).open("rb") as stream: + data = stream.read(MAX_BACKUP_BYTES + 1) + popup.dismiss() + self._preview_restore(data) + except (OSError, ValueError) as error: + self.show_message(str(error)) + + box.add_widget( + self._button("Esporta" if save_data is not None else "Apri", choose, primary=True) + ) + box.add_widget(self._button("Annulla", popup.dismiss)) + + +if __name__ == "__main__": + PureNoteApp().run() diff --git a/notes.db b/notes.db deleted file mode 100644 index 0b274b8..0000000 Binary files a/notes.db and /dev/null differ diff --git a/purenote_ui.kv b/purenote_ui.kv new file mode 100644 index 0000000..ed7c09e --- /dev/null +++ b/purenote_ui.kv @@ -0,0 +1,316 @@ +#:import t theme +#:import dp kivy.metrics.dp +#:import font theme.font +#:import NoTransition kivy.uix.screenmanager.NoTransition + +: + font_name: t.FONT_BODY + font_size: font(15) + color: t.BG if self.primary else (t.DANGER if self.danger else t.ACCENT) + disabled_color: t.MUTED + size_hint_y: None + height: max(dp(48), font(15) + dp(28)) + text_size: max(0, self.width - dp(24)), None + halign: 'center' + valign: 'middle' + shorten: True + shorten_from: 'right' + canvas.before: + Color: + rgba: t.ACCENT if self.primary else (t.LINE if self.state == 'down' else t.BUTTON) + RoundedRectangle: + pos: self.pos + size: self.size + radius: [dp(12)] + +: + text: '' + size_hint_x: None + width: self.height + canvas.after: + Color: + rgba: self.color + Line: + points: [self.center_x + dp(4), self.center_y + dp(7), self.center_x - dp(3), self.center_y, self.center_x + dp(4), self.center_y - dp(7)] if self.icon == 'back' else [] + width: dp(1.4) + Ellipse: + pos: self.center_x - dp(2), self.center_y - dp(2) + size: (dp(4), dp(4)) if self.icon == 'menu' else (0, 0) + Ellipse: + pos: self.center_x - dp(2), self.center_y + dp(6) + size: (dp(4), dp(4)) if self.icon == 'menu' else (0, 0) + Ellipse: + pos: self.center_x - dp(2), self.center_y - dp(10) + size: (dp(4), dp(4)) if self.icon == 'menu' else (0, 0) + +: + background_normal: '' + background_active: '' + background_color: t.BG + foreground_color: t.TEXT + hint_text_color: t.MUTED + cursor_color: t.ACCENT + cursor_width: dp(2) + selection_color: (0.3, 0.6, 0.4, 0.35) + font_name: t.FONT_BODY + font_size: font(18) + padding: [dp(24), dp(20)] + write_tab: False + +: + font_name: t.FONT_BODY + font_size: font(12) + color: t.MUTED + halign: 'left' + valign: 'middle' + text_size: self.width, None + shorten: True + shorten_from: 'right' + +: + size_hint_y: None + height: max(dp(48), font(15) + dp(28)) + dp(24) + padding: [dp(16), dp(12)] + spacing: dp(12) + +: + text: 'PURENOTE' + font_name: t.FONT_RETRO + font_size: min(dp(23), self.width / 5.8) + color: t.ACCENT + halign: 'left' + valign: 'middle' + text_size: self.width, None + shorten: True + +: + size_hint_y: None + height: dp(1) + canvas: + Color: + rgba: t.LINE + Rectangle: + pos: self.pos + size: self.size + +: + value: '0' + caption: '' + orientation: 'vertical' + size_hint_y: None + height: max(dp(46), font(22) + font(16) + dp(4)) + spacing: dp(4) + Label: + text: root.value + size_hint_y: None + height: font(22) + font_name: t.FONT_MONO + font_size: font(15) + color: t.TEXT + text_size: max(0, self.width - dp(8)), None + shorten: True + halign: 'center' + valign: 'middle' + Label: + text: root.caption + size_hint_y: None + height: font(16) + font_name: t.FONT_BODY + font_size: font(11) + color: t.MUTED + text_size: max(0, self.width - dp(8)), None + shorten: True + halign: 'center' + valign: 'middle' + +: + orientation: 'vertical' + size_hint_y: None + height: max(dp(112), font(24) + font(22) + font(16) + dp(44)) + padding: [dp(24), dp(16)] + spacing: dp(6) + canvas.before: + Color: + rgba: t.PANEL if self.state == 'down' else t.BG + Rectangle: + pos: self.pos + size: self.size + Color: + rgba: t.LINE + Rectangle: + pos: self.x + dp(24), self.y + size: max(0, self.width - dp(48)), dp(1) + Label: + text: root.title + font_name: t.FONT_BODY + font_size: font(18) + color: t.TEXT + halign: 'left' + valign: 'middle' + text_size: self.width, None + shorten: True + shorten_from: 'right' + size_hint_y: None + height: font(24) + QuietLabel: + text: root.snippet if root.snippet else 'Nessun altro testo' + font_size: font(14) + QuietLabel: + text: root.date + font_name: t.FONT_MONO + font_size: font(11) + size_hint_y: None + height: font(16) + +: + BoxLayout: + orientation: 'vertical' + padding: app.insets + TopBar: + Wordmark: + IconButton: + icon: 'menu' + on_release: app.open_menu() + BoxLayout: + orientation: 'vertical' + size_hint_y: None + height: self.minimum_height + padding: [dp(24), dp(8), dp(24), dp(16)] + spacing: dp(16) + BoxLayout: + size_hint_y: None + height: max(dp(36), font(25) + dp(8)) + Label: + text: 'Note' + font_name: t.FONT_BODY + font_size: font(25) + color: t.TEXT + text_size: self.width, None + halign: 'left' + shorten: True + Label: + id: note_total + text: root.summary + font_name: t.FONT_BODY + font_size: font(12) + color: t.MUTED + text_size: None, None + size_hint_x: None + width: max(dp(56), self.texture_size[0]) + halign: 'right' + NoteInput: + id: search + hint_text: 'Cerca nelle note' + multiline: False + font_size: font(16) + size_hint_y: None + height: max(dp(52), self.minimum_height) + padding: [dp(16), dp(16)] + background_color: t.PANEL + on_text: app.set_query(self.text) + FloatLayout: + RecycleView: + id: notes + pos_hint: {'x': 0, 'y': 0} + viewclass: 'NoteRow' + bar_width: dp(3) + RecycleBoxLayout: + default_size: None, max(dp(112), font(24) + font(22) + font(16) + dp(44)) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + Label: + id: empty + pos_hint: {'x': 0, 'y': 0} + text: root.empty_message + font_name: t.FONT_BODY + font_size: font(17) + color: t.MUTED + halign: 'center' + valign: 'middle' + text_size: max(0, self.width - dp(48)), self.height + BoxLayout: + orientation: 'vertical' + size_hint_y: None + height: self.minimum_height + padding: [dp(24), dp(12), dp(24), dp(16)] + spacing: dp(8) + QuietButton: + id: undo + text: 'Nota nel cestino · Annulla' + disabled: True + opacity: 0 if self.disabled else 1 + height: 0 if self.disabled else max(dp(48), font(15) + dp(28)) + on_release: app.undo_trash() + QuietButton: + text: '+ Nuova nota' + primary: True + on_release: app.new_note() + +: + BoxLayout: + orientation: 'vertical' + padding: app.insets + TopBar: + id: toolbar + IconButton: + icon: 'back' + on_release: app.go_list() + Wordmark: + halign: 'center' + IconButton: + icon: 'menu' + on_release: app.open_menu() + Divider: + NoteInput: + id: body + hint_text: 'Scrivi qui…' + line_spacing: dp(5) + on_text: root.on_text_changed(self.text) + Divider: + BoxLayout: + orientation: 'vertical' + size_hint_y: None + id: footer + height: self.minimum_height + padding: [dp(16), dp(12), dp(16), dp(8)] + spacing: dp(8) + BoxLayout: + size_hint_y: None + height: max(dp(46), font(22) + font(16) + dp(4)) + Counter: + value: root.words + caption: 'parole' + Counter: + value: root.characters + caption: 'caratteri' + Counter: + value: root.tokens + caption: 'token ≈' + QuietLabel: + text: root.status if not root.save_error else '' + size_hint_y: None + height: 0 if root.save_error else max(dp(24), font(12) + dp(8)) + halign: 'center' + QuietButton: + text: 'Salvataggio fallito · Riprova' + font_size: font(13) + danger: True + disabled: not root.save_error + opacity: 1 if root.save_error else 0 + height: max(dp(48), font(13) + dp(28)) if root.save_error else 0 + on_release: app.flush() + +RootManager: + transition: NoTransition() + canvas.before: + Color: + rgba: t.BG + Rectangle: + pos: self.pos + size: self.size + EditorScreen: + name: 'editor' + ListScreen: + name: 'list' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dfee104 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py311" +exclude = [".venv", ".buildozer"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..61343e1 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,3 @@ +buildozer==1.6.0 +Cython==0.29.34 +setuptools==80.9.0 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e3e62f6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +kivy[base]==2.3.1 +pytest==8.4.2 +ruff==0.14.2 diff --git a/stats.py b/stats.py new file mode 100644 index 0000000..b3c1284 --- /dev/null +++ b/stats.py @@ -0,0 +1,14 @@ +"""Offline text statistics with an explicitly approximate token count.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TextStats: + words: int + characters: int + estimated_tokens: int + + +def count_text(text: str) -> TextStats: + return TextStats(len(text.split()), len(text), (len(text) + 3) // 4) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b2aafaa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from db import NoteStore + + +@pytest.fixture +def store(tmp_path): + store = NoteStore(tmp_path / "notes.db") + yield store + store.close() diff --git a/tests/sign_apk.py b/tests/sign_apk.py new file mode 100644 index 0000000..292b9de --- /dev/null +++ b/tests/sign_apk.py @@ -0,0 +1,67 @@ +"""Give debug APKs a persistent identity, independent of Gradle's default key.""" + +import argparse +import hashlib +import subprocess +import tempfile +from pathlib import Path + +from verify_apk import find_signer + + +def sign_apk(apk: Path, sdk: Path, keystore: Path) -> None: + signer = find_signer(sdk) + certificate = subprocess.check_output( + [ + "keytool", + "-exportcert", + "-keystore", + str(keystore), + "-storepass", + "android", + "-alias", + "androiddebugkey", + ] + ) + expected = hashlib.sha256(certificate).hexdigest() + with tempfile.TemporaryDirectory(prefix="purenote-sign-", dir=apk.parent) as directory: + signed = Path(directory) / apk.name + subprocess.run( + [ + str(signer), + "sign", + "--ks", + str(keystore), + "--ks-key-alias", + "androiddebugkey", + "--ks-pass", + "pass:android", + "--key-pass", + "pass:android", + "--v4-signing-enabled", + "false", + "--out", + str(signed), + str(apk), + ], + check=True, + ) + details = subprocess.check_output( + [str(signer), "verify", "--print-certs", str(signed)], text=True + ) + if f"certificate SHA-256 digest: {expected}" not in details: + raise ValueError("APK certificate differs from the persistent debug key") + signed.replace(apk) + print(f"{apk.name}: persistent debug certificate SHA-256 {expected}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("apks", nargs="+", type=Path) + parser.add_argument("--keystore", required=True, type=Path) + parser.add_argument( + "--sdk", type=Path, default=Path.home() / ".buildozer/android/platform/android-sdk" + ) + args = parser.parse_args() + for apk in args.apks: + sign_apk(apk, args.sdk, args.keystore) diff --git a/tests/test_backup_stats.py b/tests/test_backup_stats.py new file mode 100644 index 0000000..99628c9 --- /dev/null +++ b/tests/test_backup_stats.py @@ -0,0 +1,107 @@ +import json +import sqlite3 +from dataclasses import asdict + +import pytest + +from backup import MAX_BACKUP_BYTES, export_backup, read_backup +from stats import count_text + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("", (0, 0, 0)), + ("Ciao mondo", (2, 10, 3)), + ("È già qui\n🙂", (4, 11, 3)), + ("a b\n\tc", (3, 7, 2)), + ("e\u0301", (1, 2, 1)), + ("👨‍👩‍👧", (1, 5, 2)), + ], +) +def test_counters(text, expected): + assert tuple(asdict(count_text(text)).values()) == expected + + +def make_backup(store): + active = store.save_note("È una nota\n🙂\n- [ ] Testo Markdown") + deleted = store.save_note("Cestino") + with store.conn: + store.conn.execute( + "UPDATE notes SET tags=?,pinned=1,created_at=12 WHERE id=?", ("vecchi,tag", active) + ) + store.set_trashed(deleted, True) + return export_backup(store) + + +def test_backup_round_trip_is_additive_and_preserves_all_metadata(store): + data = make_backup(store) + original = store.all_notes() + parsed = read_backup(data) + assert store.import_notes(parsed) == 2 + copies = store.all_notes()[2:] + for before, after in zip(original, copies): + assert after.id != before.id + assert {k: v for k, v in asdict(before).items() if k != "id"} == { + k: v for k, v in asdict(after).items() if k != "id" + } + assert len(store.list_notes()) == 2 + assert len(store.list_notes(trashed=True)) == 2 + + +@pytest.mark.parametrize( + "bad", + [ + b"", + b"\xff", + b"{", + b"[]", + b'{"app":"PureNote","version":true,"notes":[]}', + b'{"app":"PureNote","version":99,"notes":[]}', + b'{"app":"Other","version":1,"notes":[]}', + ], +) +def test_corrupt_backup_is_rejected_without_changes(store, bad): + store.save_note("Conservata") + with pytest.raises(ValueError): + read_backup(bad) + assert [n.body for n in store.all_notes()] == ["Conservata"] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("body", None), + ("body", "\ud800"), + ("pinned", 2), + ("trashed", True), + ("updated_at", -1), + ("created_at", 2**80), + ("tags", []), + ], +) +def test_invalid_later_record_prevents_partial_import(store, field, value): + document = json.loads(make_backup(store)) + document["notes"][1][field] = value + before = store.all_notes() + with pytest.raises(ValueError): + store.import_notes(read_backup(json.dumps(document).encode())) + assert store.all_notes() == before + + +def test_sql_failure_during_import_rolls_back_all_rows(store): + records = read_backup(make_backup(store)) + records[1]["body"] = None + before = store.all_notes() + with pytest.raises(sqlite3.IntegrityError): + store.import_notes(records) + assert store.all_notes() == before + + +def test_size_limit(store): + with pytest.raises(ValueError, match="32 MB"): + read_backup(b" " * (MAX_BACKUP_BYTES + 1)) + + +def test_empty_backup_is_valid(store): + assert read_backup(export_backup(store)) == [] diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 0000000..2f7c36e --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,124 @@ +import sqlite3 +from dataclasses import dataclass + +from editor import EditorSession + + +@dataclass +class Event: + when: float + callback: object + cancelled: bool = False + + def cancel(self): + self.cancelled = True + + +class FakeClock: + def __init__(self): + self.now = 0.0 + self.events = [] + + def schedule(self, callback, delay): + event = Event(self.now + delay, callback) + self.events.append(event) + return event + + def advance(self, delta): + target = self.now + delta + while pending := [e for e in self.events if not e.cancelled and e.when <= target]: + event = min(pending, key=lambda e: e.when) + self.now = event.when + event.cancelled = True + event.callback() + self.now = target + + +def session_for(store): + clock = FakeClock() + return EditorSession(store, clock.schedule), clock + + +def test_debounce_saves_once_after_typing_pauses(store): + session, clock = session_for(store) + session.edit("C") + clock.advance(0.2) + session.edit("Ciao") + clock.advance(0.299) + assert store.all_notes() == [] + clock.advance(0.002) + assert len(store.all_notes()) == 1 + assert store.get(session.note_id).body == "Ciao" + assert not session.dirty + + +def test_continuous_typing_still_saves_within_two_seconds(store): + session, clock = session_for(store) + for index in range(10): + session.edit("a" * (index + 1)) + clock.advance(0.2) + clock.advance(0.001) + assert store.get(session.note_id).body == "a" * 10 + session.edit("Ultima versione") + clock.advance(0.301) + assert len(store.all_notes()) == 1 + assert store.get(session.note_id).body == "Ultima versione" + + +def test_fast_navigation_flushes_old_note_and_cancels_stale_callbacks(store): + session, clock = session_for(store) + second = store.save_note("Seconda") + session.edit("Prima") + assert session.open(second) + first = next(n for n in store.all_notes() if n.body == "Prima") + session.edit("Seconda modificata") + assert session.open(first.id) + clock.advance(5) + assert store.get(first.id).body == "Prima" + assert store.get(second).body == "Seconda modificata" + assert session.text == "Prima" + + +def test_new_blank_drafts_are_ignored_but_existing_empty_notes_saved(store): + session, clock = session_for(store) + session.edit(" \n") + clock.advance(0.3) + assert store.all_notes() == [] + session.edit("Nota") + session.flush() + note_id = session.note_id + session.edit("") + session.flush() + assert store.get(note_id).body == "" + + +def test_failed_save_blocks_navigation_retains_text_and_retries(store, monkeypatch): + session, clock = session_for(store) + other = store.save_note("Altra") + real_save = store.save_note + + def fail(*_): + raise sqlite3.OperationalError("disk full") + + monkeypatch.setattr(store, "save_note", fail) + session.edit("Testo prezioso") + clock.advance(0.3) + assert session.error == "disk full" + assert session.dirty + assert not session.open(other) + assert session.text == "Testo prezioso" + assert session.note_id is None + monkeypatch.setattr(store, "save_note", real_save) + clock.advance(2.01) + assert not session.error + assert not session.dirty + assert store.get(session.note_id).body == "Testo prezioso" + + +def test_flush_for_background_or_close_does_not_duplicate_note(store): + session, clock = session_for(store) + session.edit("Subito") + session.flush() + session.flush() + clock.advance(4) + assert [note.body for note in store.all_notes()] == ["Subito"] diff --git a/tests/test_scaling.py b/tests/test_scaling.py new file mode 100644 index 0000000..c6379ee --- /dev/null +++ b/tests/test_scaling.py @@ -0,0 +1,41 @@ +"""Android display density and font preferences must remain independent.""" + +import sys +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize("density", [1.0, 2.625, 3.5]) +@pytest.mark.parametrize("font_scale", [1.0, 1.5, 2.0]) +def test_android_uses_display_density_and_native_font_curve(monkeypatch, density, font_scale): + import theme + + display = SimpleNamespace(density=density, scaledDensity=density * font_scale) + resources = SimpleNamespace( + getDisplayMetrics=lambda: display, + getConfiguration=lambda: SimpleNamespace(fontScale=font_scale), + ) + activity = SimpleNamespace(getResources=lambda: resources) + + def native_size(unit, size, metrics): + assert unit == 2 + assert metrics is display + # A nonlinear curve deliberately differs from multiplying by fontScale. + scale = font_scale if size <= 14 else 1 + (font_scale - 1) * 0.7 + return density * size * scale + + classes = { + "org.kivy.android.PythonActivity": SimpleNamespace(mActivity=activity), + "android.util.TypedValue": SimpleNamespace(COMPLEX_UNIT_SP=2, applyDimension=native_size), + } + monkeypatch.setitem(sys.modules, "jnius", SimpleNamespace(autoclass=classes.__getitem__)) + monkeypatch.setattr(theme, "Metrics", SimpleNamespace(density=0, fontscale=0)) + monkeypatch.setattr(theme, "_android_font", None) + + theme.configure_android_scaling() + + assert theme.Metrics.density == density + assert theme.Metrics.fontscale == font_scale + assert theme.font(11) == pytest.approx(native_size(2, 11, display)) + assert theme.font(18) == pytest.approx(native_size(2, 18, display)) diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..04eb24a --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,183 @@ +import sqlite3 + +import pytest + +from db import SCHEMA_VERSION, NoteStore, merge_legacy_title, title_from_text + + +def test_new_database_is_empty_and_does_not_seed_notes(store): + assert store.all_notes() == [] + assert store.last_note() is None + + +def test_edit_keeps_metadata_and_empty_existing_note(store): + note_id = store.save_note("Titolo\nCorpo") + with store.conn: + store.conn.execute( + "UPDATE notes SET tags=?,pinned=1,created_at=42 WHERE id=?", ("casa,info", note_id) + ) + store.save_note("", note_id) + note = store.get(note_id) + assert (note.title, note.body, note.tags, note.pinned, note.created_at) == ( + "Senza titolo", + "", + "casa,info", + 1, + 42, + ) + + +def test_last_opened_survives_restart_and_missing_note_falls_back(tmp_path): + path = tmp_path / "notes.db" + store = NoteStore(path) + first = store.save_note("Prima") + second = store.save_note("Seconda") + store.remember(first) + store.close() + store = NoteStore(path) + assert store.last_note().id == first + store.set_trashed(first, True) + assert store.last_note().id == second + store.set_trashed(second, True) + assert store.last_note() is None + store.close() + + +def test_search_is_unicode_case_insensitive_and_treats_wildcards_literally(store): + first = store.save_note("È già qui: 100%_vero") + store.save_note("Altro testo") + assert [n.id for n in store.list_notes("È GIÀ")] == [first] + assert [n.id for n in store.list_notes("%_")] == [first] + assert store.list_notes("assente") == [] + + +def test_recent_order_ignores_old_pins_and_breaks_ties_by_id(store): + first = store.save_note("Prima") + second = store.save_note("Seconda") + with store.conn: + store.conn.execute("UPDATE notes SET updated_at=100") + store.conn.execute("UPDATE notes SET pinned=1 WHERE id=?", (first,)) + assert [n.id for n in store.list_notes()] == [second, first] + + +def test_trash_restore_and_permanent_deletion_only_affect_trash(store): + active = store.save_note("Attiva") + deleted = store.save_note("Cestinata") + store.set_trashed(deleted, True) + assert [n.id for n in store.list_notes(trashed=True)] == [deleted] + store.set_trashed(deleted, False) + assert len(store.list_notes()) == 2 + store.delete_forever(active) + assert store.get(active) is not None + store.set_trashed(deleted, True) + store.delete_forever() + assert store.get(deleted) is None + assert store.get(active) is not None + with pytest.raises(ValueError): + store.save_note("Nuovo testo", deleted) + + +def test_migrate_original_single_column_database_once(tmp_path): + path = tmp_path / "notes.db" + with sqlite3.connect(path) as conn: + conn.execute("CREATE TABLE notes(note TEXT)") + conn.executemany("INSERT INTO notes VALUES(?)", [("Originale\nÈ qui",), ("",), (None,)]) + store = NoteStore(path) + assert [n.body for n in store.all_notes()] == ["Originale\nÈ qui", "", ""] + assert store.conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + store.close() + assert (tmp_path / "notes.db.pre-v3.bak").exists() + store = NoteStore(path) + assert len(store.all_notes()) == 3 + assert len(list(tmp_path.glob("*.bak"))) == 1 + store.close() + + +def make_v2_database(path, *, trashed_column=True): + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE notes(id INTEGER PRIMARY KEY,title TEXT,body TEXT,tags TEXT," + "pinned INTEGER,created_at INTEGER,updated_at INTEGER" + + (",trashed INTEGER DEFAULT 0)" if trashed_column else ")") + ) + conn.execute( + "INSERT INTO notes(id,title,body,tags,pinned,created_at,updated_at) VALUES(7,?,?,?,?,?,?)", + ("Titolo separato", "Corpo intatto", "lavoro", 1, 11, 22), + ) + conn.execute( + "INSERT INTO notes(id,title,body,tags,pinned,created_at,updated_at) VALUES(8,?,?,?,?,?,?)", + ("Già presente", "# Già presente\nTesto", "casa", 0, 33, 44), + ) + conn.commit() + return conn + + +def test_migrate_v2_preserves_titles_dates_tags_pins_and_trash(tmp_path): + path = tmp_path / "notes.db" + conn = make_v2_database(path) + conn.execute("UPDATE notes SET trashed=1 WHERE id=7") + conn.commit() + conn.close() + store = NoteStore(path) + note = store.get(7) + assert (note.body, note.tags, note.pinned, note.created_at, note.updated_at, note.trashed) == ( + "Titolo separato\n\nCorpo intatto", + "lavoro", + 1, + 11, + 22, + 1, + ) + assert store.get(8).body == "# Già presente\nTesto" + backup = sqlite3.connect(str(path) + ".pre-v3.bak") + assert backup.execute("SELECT body FROM notes WHERE id=7").fetchone()[0] == "Corpo intatto" + backup.close() + store.close() + + +def test_failed_migration_rolls_back_data_and_schema(tmp_path): + path = tmp_path / "notes.db" + conn = make_v2_database(path, trashed_column=False) + conn.execute( + "CREATE TRIGGER reject_update BEFORE UPDATE ON notes WHEN NEW.id=8 " + "BEGIN SELECT RAISE(ABORT, 'simulated failure'); END" + ) + conn.commit() + conn.close() + with pytest.raises(sqlite3.IntegrityError, match="simulated failure"): + NoteStore(path) + conn = sqlite3.connect(path) + assert conn.execute("SELECT body FROM notes WHERE id=7").fetchone()[0] == "Corpo intatto" + assert "trashed" not in [r[1] for r in conn.execute("PRAGMA table_info(notes)")] + assert conn.execute("PRAGMA user_version").fetchone()[0] == 0 + conn.close() + + +@pytest.mark.parametrize( + ("title", "body", "expected"), + [ + ("Nome", "Nome\nTesto", "Nome\nTesto"), + ("Nome", "# Nome\nTesto", "# Nome\nTesto"), + ("Diverso", "Testo", "Diverso\n\nTesto"), + ("", "Testo", "Testo"), + ("Senza titolo", "", ""), + ("x" * 60, "x" * 100, "x" * 100), + ], +) +def test_legacy_title_conversion(title, body, expected): + assert merge_legacy_title(title, body) == expected + + +def test_title_uses_first_nonblank_line_without_changing_source(): + assert title_from_text("\n \n Una nota \nAltro") == "Una nota" + assert title_from_text(" \n") == "Senza titolo" + + +def test_newer_schema_is_not_modified(tmp_path): + path = tmp_path / "future.db" + with sqlite3.connect(path) as conn: + conn.execute("PRAGMA user_version=99") + with pytest.raises(ValueError, match="recente"): + NoteStore(path) + with sqlite3.connect(path) as conn: + assert conn.execute("PRAGMA user_version").fetchone()[0] == 99 diff --git a/tests/ui_smoke.py b/tests/ui_smoke.py new file mode 100644 index 0000000..3a049c9 --- /dev/null +++ b/tests/ui_smoke.py @@ -0,0 +1,213 @@ +"""Exercise the real Kivy event loop; run under Xvfb in CI or a desktop session.""" + +import os +import sqlite3 +import sys +import tempfile +from pathlib import Path + +os.environ["KIVY_NO_ARGS"] = "1" +os.environ.setdefault("KIVY_HOME", "/tmp/purenote-test-kivy") +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from kivy.clock import Clock # noqa: E402 +from kivy.core.window import Window # noqa: E402 +from kivy.metrics import dp # noqa: E402 +from kivy.uix.label import Label # noqa: E402 + +from main import PureNoteApp # noqa: E402 + +OUTPUT = Path(os.environ.get("PURENOTE_SCREENSHOTS", "/tmp/purenote-preview")) +OUTPUT.mkdir(parents=True, exist_ok=True) +Window.size = (dp(390), dp(780)) + + +class SmokeApp(PureNoteApp): + failure = None + + def on_start(self): + super().on_start() + self.steps = iter( + [ + self.first_run, + self.autosaved, + self.editor_capture, + self.failure_and_retry, + self.save_error_capture, + self.navigation, + self.list_capture, + self.trash_and_restore, + self.menu_capture, + self.keyboard_layout, + self.keyboard_dialog, + self.narrow_screen, + self.counts_dialog, + self.counts_capture, + self.finish, + ] + ) + Clock.schedule_once(self.next_step, 0.5) + + def next_step(self, _dt): + try: + next(self.steps)() + except StopIteration: + self.stop() + return + except Exception as error: + self.failure = error + self.stop() + return + Clock.schedule_once(self.next_step, 0.4) + + def first_run(self): + assert self.root.current == "editor" + assert not self.store.all_notes() + assert self.editor.ids.body.width == Window.width + self.editor.ids.body.text = ( + "Una cosa alla volta\n\n" + "Un posto tranquillo per raccogliere idee, parole e piccoli promemoria.\n\n" + "Oggi voglio lasciare più spazio alle cose semplici." + ) + + def autosaved(self): + self.first_id = self.session.note_id + assert self.first_id + assert self.store.get(self.first_id).body == self.editor.ids.body.text + assert self.editor.status == "Tutto salvato" + + def editor_capture(self): + assert self.editor.counts.startswith("22 parole") + assert self.editor.status == "Tutto salvato" + self.assert_editor_layout() + self.root.export_to_png(str(OUTPUT / "editor.png")) + + def failure_and_retry(self): + self.real_save = self.store.save_note + + def fail(*_): + raise sqlite3.OperationalError("simulated disk full") + + self.store.save_note = fail + self.editor.ids.body.text += "\nUna modifica preziosa." + self.go_list() + assert self.root.current == "editor" + assert self.session.dirty + assert self.editor.save_error + self._popup.dismiss() + + def save_error_capture(self): + self.assert_editor_layout() + self.root.export_to_png(str(OUTPUT / "save-error.png")) + self.store.save_note = self.real_save + self.editor.ids.body.text = self.editor.ids.body.text.removesuffix( + "\nUna modifica preziosa." + ) + assert self.flush() + self.on_pause() + self.on_resume() + + def navigation(self): + self.new_note() + self.editor.ids.body.text = "Idee per domani\n\nUna passeggiata senza fretta." + self.go_list() + self.second_id = self.session.note_id + assert self.store.get(self.second_id).body.startswith("Idee per domani") + assert len(self.store.list_notes()) == 2 + + def list_capture(self): + screen = self.root.get_screen("list") + notes = screen.ids.notes + assert notes.pos == notes.parent.pos + assert notes.top == notes.parent.top + assert not screen.ids.note_total.is_shortened + assert screen.ids.note_total.texture_size[1] <= screen.ids.note_total.height + 1 + self.root.export_to_png(str(OUTPUT / "list.png")) + self.set_query("DOMANI") + self.refresh_list() + assert len(self.root.get_screen("list").ids.notes.data) == 1 + self.set_query("") + self.refresh_list() + self.open_editor(self.first_id) + + def trash_and_restore(self): + self.trash_note() + assert self.store.get(self.first_id).trashed + self.undo_trash() + assert not self.store.get(self.first_id).trashed + assert self.session.note_id == self.first_id + self.open_menu() + + def menu_capture(self): + self._popup.export_to_png(str(OUTPUT / "menu.png")) + self._popup.dismiss() + # Simulate the layout reduction, without pretending this is a real IME test. + self.insets = [0, dp(24), 0, dp(290)] + + def keyboard_layout(self): + assert self.editor.ids.body.height > dp(120) + assert self.editor.ids.body.y >= dp(290) + self.assert_editor_layout() + self.root.export_to_png(str(OUTPUT / "keyboard-layout.png")) + self.explain_counts() + + def keyboard_dialog(self): + self._popup.export_to_png(str(OUTPUT / "keyboard-dialog.png")) + assert self._popup.y >= self.insets[3] - 1, ( + self._popup.pos, + self._popup.size, + self._popup.safe_center, + Window.size, + self.insets, + ) + assert self._popup.top <= Window.height - self.insets[1] + 1 + self._popup.dismiss() + self.insets = [0, 0, 0, 0] + Window.size = (dp(320), dp(568)) + + def narrow_screen(self): + assert self.editor.ids.body.width == dp(320) + assert self.editor.ids.body.height > dp(160) + self.assert_editor_layout() + self.root.export_to_png(str(OUTPUT / "small-screen.png")) + self.explain_counts() + + def counts_dialog(self): + assert self._popup is not None + assert self._popup.width <= Window.width + assert self._popup.height <= Window.height + + def assert_editor_layout(self): + """Catch text bleeding and overlapping regions at Android densities.""" + body = self.editor.ids.body + toolbar = self.editor.ids.toolbar + footer = self.editor.ids.footer + assert body.top <= toolbar.y + 1 + assert footer.top <= body.y + 1 + for region in (toolbar, footer): + for widget in region.walk(): + if isinstance(widget, Label) and widget.text and widget.height: + assert widget.texture_size[1] <= widget.height + 1, ( + widget.text, + widget.texture_size, + widget.size, + ) + + def counts_capture(self): + self._popup.export_to_png(str(OUTPUT / "counts.png")) + self._popup.dismiss() + + def finish(self): + assert not self.session.dirty + assert len(self.store.list_notes()) == 2 + print( + "UI smoke: editor, autosave, save failure/retry, navigation, search, trash/undo, IME layout OK" + ) + + +with tempfile.TemporaryDirectory(prefix="purenote-ui-") as directory: + os.environ["PURENOTE_DB"] = str(Path(directory) / "notes.db") + app = SmokeApp() + app.run() + if app.failure: + raise app.failure diff --git a/tests/verify_apk.py b/tests/verify_apk.py new file mode 100644 index 0000000..52e6cc6 --- /dev/null +++ b/tests/verify_apk.py @@ -0,0 +1,65 @@ +"""Check the actual Android artifact before publishing it in CI.""" + +import argparse +import re +import subprocess +from pathlib import Path + +from verify_signing import fingerprint + + +def find_signer(sdk: Path) -> Path: + candidates = list((sdk / "build-tools").glob("*/apksigner")) + if not candidates: + raise FileNotFoundError(f"Android build tools not found in {sdk}") + return max(candidates, key=lambda path: tuple(map(int, re.findall(r"\d+", path.parent.name)))) + + +def verify_apk( + apk: Path, sdk: Path, expected_sha256: str | None = None, release: bool = False +) -> None: + if release and expected_sha256 is None: + raise ValueError("Release verification requires the confirmed Play app certificate") + signer = find_signer(sdk) + subprocess.run([str(signer), "verify", "--verbose", str(apk)], check=True) + if expected_sha256 is not None: + expected = fingerprint(expected_sha256) + details = subprocess.check_output( + [str(signer), "verify", "--print-certs", str(apk)], text=True + ) + actual = re.findall( + r"^Signer #\d+ certificate SHA-256 digest: ([0-9a-f]+)$", + details, + re.MULTILINE, + ) + if actual != [expected]: + raise ValueError("APK certificate does not match the confirmed Play app certificate") + manifest = subprocess.check_output( + [str(signer.parent / "aapt"), "dump", "badging", str(apk)], text=True + ) + permissions = [line for line in manifest.splitlines() if line.startswith("uses-permission")] + if permissions: + raise ValueError( + "PureNote should not request Android permissions:\n" + "\n".join(permissions) + ) + if release and "application-debuggable" in manifest.splitlines(): + raise ValueError("The official release APK must not be debuggable") + for line in manifest.splitlines(): + if line.startswith(("package:", "minSdkVersion:", "targetSdkVersion:", "native-code:")): + print(line) + print("APK signature and permission checks passed.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("apks", nargs="+", type=Path) + parser.add_argument("--expected-sha256") + parser.add_argument("--release", action="store_true") + parser.add_argument( + "--sdk", + type=Path, + default=Path.home() / ".buildozer/android/platform/android-sdk", + ) + args = parser.parse_args() + for apk in args.apks: + verify_apk(apk, args.sdk, args.expected_sha256, args.release) diff --git a/tests/verify_signing.py b/tests/verify_signing.py new file mode 100644 index 0000000..0f3843d --- /dev/null +++ b/tests/verify_signing.py @@ -0,0 +1,55 @@ +"""Require an independently confirmed certificate before signing a release.""" + +import argparse +import base64 +import hashlib +import os +import re +import subprocess +from pathlib import Path + + +def fingerprint(value: str) -> str: + value = value.strip().replace(":", "").lower() + if not re.fullmatch(r"[0-9a-f]{64}", value): + raise ValueError("Expected a confirmed SHA-256 certificate fingerprint") + return value + + +def verify_signing(expected: str, artifact: Path | None = None) -> None: + expected = fingerprint(expected) + if artifact is None: + command = [ + "keytool", "-exportcert", + "-keystore", os.environ["P4A_RELEASE_KEYSTORE"], + "-alias", os.environ["P4A_RELEASE_KEYALIAS"], + "-storepass:env", "P4A_RELEASE_KEYSTORE_PASSWD", + ] + certificate = subprocess.check_output(command) + else: + if artifact.suffix != ".aab": + raise ValueError("Only AAB certificate verification is supported here") + subprocess.run(["jarsigner", "-verify", str(artifact)], check=True) + details = subprocess.check_output( + ["keytool", "-printcert", "-rfc", "-jarfile", str(artifact)], text=True + ) + certificates = re.findall( + r"-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----", + details, + re.DOTALL, + ) + if not certificates: + raise ValueError("The AAB has no signing certificate") + certificate = base64.b64decode(certificates[0]) + actual = hashlib.sha256(certificate).hexdigest() + if actual != expected: + raise ValueError("Signing certificate does not match the confirmed Play certificate") + print(f"Confirmed signing certificate SHA-256: {actual}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expected-sha256", required=True) + parser.add_argument("artifact", nargs="?", type=Path) + args = parser.parse_args() + verify_signing(args.expected_sha256, args.artifact) diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..86f27de --- /dev/null +++ b/theme.py @@ -0,0 +1,55 @@ +"""A restrained terminal palette, with ordinary, readable writing typography.""" + +from pathlib import Path + +from kivy.metrics import Metrics, sp +from kivy.resources import resource_find + +BG = (0.047, 0.071, 0.059, 1) +PANEL = (0.071, 0.102, 0.082, 1) +BUTTON = (0.10, 0.145, 0.115, 1) +LINE = (0.16, 0.22, 0.18, 1) +ACCENT = (0.64, 0.86, 0.69, 1) +TEXT = (0.86, 0.90, 0.87, 1) +MUTED = (0.55, 0.65, 0.58, 1) +DANGER = (1.0, 0.61, 0.58, 1) +FONT_BODY = resource_find("data/fonts/Roboto-Regular.ttf") +FONT_MONO = resource_find("data/fonts/RobotoMono-Regular.ttf") +FONT_RETRO = str(Path(__file__).resolve().parent / "GS.ttf") + +_android_font = None + + +def font(size: float) -> float: + """Convert text sizes through Android's font scaling curve when available.""" + return _android_font(size) if _android_font else sp(size) + + +def configure_android_scaling(): + """Separate display density from font scaling, including Android 14+ curves.""" + from functools import lru_cache + + from jnius import autoclass + from kivy.logger import Logger + + global _android_font + activity = autoclass("org.kivy.android.PythonActivity").mActivity + resources = activity.getResources() + display = resources.getDisplayMetrics() + typed_value = autoclass("android.util.TypedValue") + # Kivy 2.3.1 reads scaledDensity for dp and scales sp a second time. + # Layout uses the physical display density; text uses Android's converter. + Metrics.density = display.density + Metrics.fontscale = resources.getConfiguration().fontScale + + @lru_cache(maxsize=64) + def android_font(size): + return typed_value.applyDimension(typed_value.COMPLEX_UNIT_SP, float(size), display) + + _android_font = android_font + Logger.info( + "PureNote: density=%s fontScale=%s text18px=%s", + Metrics.density, + Metrics.fontscale, + font(18), + )