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.
+
+
+
+
-# Screenshot
-, 
+## 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.
-[](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 @@
+
\ 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 @@
+
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),
+ )