From 4e695c12e8a53deac50eca0078e28a4d91c91e9a Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 15 Jun 2026 17:08:30 +0200 Subject: [PATCH 01/29] Refresh PureNote to v2: multi-note app + retro restyle + CI Rebuild the original single-textbox note app into a full local note manager, still Python/Kivy, 100% offline. Features: - New SQLite schema (id, title, body, tags, pinned, created_at, updated_at) with automatic migration from the legacy notes(note) table - Titles, tags, creation/modification dates, pin-to-top, word/char counter - Live search and tag filtering - Markdown editor with live preview (headings, bold, italic, code, bullet lists and - [ ] / - [x] checklists) - Export note as Markdown, share intent, full DB backup - Retro/CRT phosphor-green restyle: full screens (no popups), styled cards, tag chips, top bars, FAB Tooling: - buildozer.spec (target API 35 for Play Store, minapi 24) - GitHub Actions workflow to build the debug APK and upload it as an artifact Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-apk.yml | 59 ++++ .gitignore | 10 + README.md | 28 +- buildozer.spec | 69 +++++ db.py | 141 +++++++++ main.py | 510 +++++++++++++++++++++----------- md.py | 84 ++++++ notes.db | Bin 8192 -> 12288 bytes purenote_ui.kv | 336 +++++++++++++++++++++ theme.py | 36 +++ 10 files changed, 1100 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/build-apk.yml create mode 100644 .gitignore create mode 100644 buildozer.spec create mode 100644 db.py create mode 100644 md.py create mode 100644 purenote_ui.kv create mode 100644 theme.py diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..51620bb --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,59 @@ +name: Build PureNote APK + +on: + push: + branches: ["**"] + workflow_dispatch: + +jobs: + build-android: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + git zip unzip openjdk-17-jdk python3-pip autoconf libtool \ + pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev \ + libtinfo5 cmake libffi-dev libssl-dev build-essential \ + ccache libltdl-dev + + - name: Install buildozer + run: | + python -m pip install --upgrade pip + pip install --upgrade buildozer + pip install "cython==0.29.36" + + - name: Cache buildozer global directory (SDK/NDK) + uses: actions/cache@v4 + with: + path: ~/.buildozer + key: buildozer-global-${{ hashFiles('buildozer.spec') }} + restore-keys: | + buildozer-global- + + - name: Cache buildozer local directory + uses: actions/cache@v4 + with: + path: .buildozer + key: buildozer-local-${{ hashFiles('buildozer.spec') }} + restore-keys: | + buildozer-local- + + - name: Build debug APK + run: buildozer android debug + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: purenote-debug-apk + path: bin/*.apk + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6dbbb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +*.bak +*.orig +notes.db.orig.bak +PureNote_export/ +.buildozer/ +bin/ +.DS_Store diff --git a/README.md b/README.md index e244cda..bc25ad2 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,29 @@ # PureNote -Basic note-taking application for Android, built with Python and Kivy. Store your notes locally for quick access and privacy. Android app. +A retro/CRT note-taking app for Android, built with Python and Kivy. 100% local and private — no cloud, no account. Your notes live in a local SQLite database. # Features -Create and Edit Notes -Save Notes Locally -Counter letters in the notes -Easy Navigation +- Multi-note with titles, tags and creation/modification dates +- Live search across titles and bodies +- Tag filtering +- Markdown editor with live preview (headings, bold, italic, code, bullet & checklists) +- Pin important notes to the top +- Word / character counter +- Export a note as Markdown and full database backup +- Retro phosphor-green CRT theme + +# Run (desktop) +``` +python3 -m venv .venv +.venv/bin/pip install "kivy[base]" +.venv/bin/python main.py +``` + +# Build (Android) +``` +pip install buildozer +buildozer -v android debug # APK +buildozer -v android release # AAB +``` # Screenshot diff --git a/buildozer.spec b/buildozer.spec new file mode 100644 index 0000000..5cf88fd --- /dev/null +++ b/buildozer.spec @@ -0,0 +1,69 @@ +[app] + +# (str) Title of your application +title = PureNote + +# (str) Package name +package.name = purenote + +# (str) Package domain (needed for android/ios packaging) +package.domain = bbnss.test + +# (str) Source code where the main.py live +source.dir = . + +# (list) Source files to include (let empty to include all the files) +source.include_exts = py,png,jpg,ttf,kv,db + +# (list) Source files to exclude +source.exclude_exts = bak,orig +source.exclude_dirs = .venv,images,__pycache__,PureNote_export + +# (str) Application versioning +version = 2.0 + +# (list) Application requirements +requirements = python3,kivy==2.3.1,plyer + +# (str) Presplash of the application +presplash.filename = %(source.dir)s/logo.png + +# (str) Icon of the application +icon.filename = %(source.dir)s/logo.png + +# (list) Supported orientations +orientation = portrait + +# (bool) Indicate if the application should be fullscreen or not +fullscreen = 0 + +# +# Android specific +# + +# (list) Permissions +android.permissions = WRITE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE + +# (int) Target Android API, should be as high as possible. +# Google Play currently requires target API 35 (Android 15) for new apps/updates. +android.api = 35 + +# (int) Minimum API your APK / AAB will support. +android.minapi = 24 + +# (str) Android NDK version to use +# android.ndk = 25b + +# (list) The Android archs to build for +android.archs = arm64-v8a, armeabi-v7a + +# (bool) enables Android auto backup feature (Android API >= 23) +android.allow_backup = True + +[buildozer] + +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 + +# (int) Display warning if buildozer is run as root (0 = False, 1 = True) +warn_on_root = 1 diff --git a/db.py b/db.py new file mode 100644 index 0000000..0286145 --- /dev/null +++ b/db.py @@ -0,0 +1,141 @@ +"""SQLite data layer for PureNote. + +Holds the notes store and transparently migrates the legacy single-column +``notes(note text)`` table to the new schema on first run. +""" + +import os +import sqlite3 +import time + +# On Android the cwd is read-only, so the app overrides this with the +# user data dir. On desktop we just use a file next to the source. +DB_PATH = os.environ.get("PURENOTE_DB", "notes.db") + + +def _now(): + return int(time.time()) + + +class NoteStore: + def __init__(self, path=None): + self.path = path or DB_PATH + self.conn = sqlite3.connect(self.path) + self.conn.row_factory = sqlite3.Row + self._init_schema() + + # ------------------------------------------------------------------ schema + def _init_schema(self): + # The legacy app shipped a ``notes`` table with a single ``note`` + # column. Detect it before creating the new table (same name) and + # stash it aside so we can migrate its rows. + self._stash_legacy_table() + 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 + )""" + ) + self.conn.commit() + self._migrate_legacy() + + def _table_columns(self, table): + return [r[1] for r in self.conn.execute(f"PRAGMA table_info({table})")] + + def _stash_legacy_table(self): + cols = self._table_columns("notes") + # New schema already in place -> nothing to stash. + if "id" in cols or "title" in cols: + return + if "note" in cols: + self.conn.execute("ALTER TABLE notes RENAME TO notes_legacy") + self.conn.commit() + + def _migrate_legacy(self): + """Move rows from the stashed ``notes_legacy`` table, if any.""" + if "note" not in self._table_columns("notes_legacy"): + return + rows = self.conn.execute("SELECT note FROM notes_legacy").fetchall() + for (note,) in rows: + body = note or "" + self.add(self._title_from_body(body), body) + self.conn.execute("DROP TABLE notes_legacy") + self.conn.commit() + + @staticmethod + def _title_from_body(body): + first = (body or "").strip().splitlines()[0] if body.strip() else "" + first = first.lstrip("# ").strip() + return (first[:60]) or "Senza titolo" + + # ------------------------------------------------------------------- CRUD + def add(self, title, body, tags="", pinned=0): + ts = _now() + cur = self.conn.execute( + "INSERT INTO notes (title, body, tags, pinned, created_at, updated_at)" + " VALUES (?,?,?,?,?,?)", + (title, body, tags, int(pinned), ts, ts), + ) + self.conn.commit() + return cur.lastrowid + + def update(self, note_id, title, body, tags="", pinned=None): + if pinned is None: + self.conn.execute( + "UPDATE notes SET title=?, body=?, tags=?, updated_at=? WHERE id=?", + (title, body, tags, _now(), note_id), + ) + else: + self.conn.execute( + "UPDATE notes SET title=?, body=?, tags=?, pinned=?, updated_at=? WHERE id=?", + (title, body, tags, int(pinned), _now(), note_id), + ) + self.conn.commit() + + def set_pinned(self, note_id, pinned): + self.conn.execute( + "UPDATE notes SET pinned=?, updated_at=? WHERE id=?", + (int(pinned), _now(), note_id), + ) + self.conn.commit() + + def delete(self, note_id): + self.conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) + self.conn.commit() + + def get(self, note_id): + row = self.conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() + return dict(row) if row else None + + # ----------------------------------------------------------------- queries + def list(self, query="", tag=""): + sql = "SELECT * FROM notes" + clauses, params = [], [] + if query: + clauses.append("(title LIKE ? OR body LIKE ?)") + like = f"%{query}%" + params += [like, like] + if tag: + clauses.append("(',' || replace(tags,' ','') || ',') LIKE ?") + params.append(f"%,{tag},%") + if clauses: + sql += " WHERE " + " AND ".join(clauses) + sql += " ORDER BY pinned DESC, updated_at DESC" + return [dict(r) for r in self.conn.execute(sql, params).fetchall()] + + def all_tags(self): + tags = set() + for (raw,) in self.conn.execute("SELECT tags FROM notes WHERE tags != ''"): + for t in raw.split(","): + t = t.strip() + if t: + tags.add(t) + return sorted(tags) + + def close(self): + self.conn.close() diff --git a/main.py b/main.py index c20ddea..e40dac0 100644 --- a/main.py +++ b/main.py @@ -1,174 +1,348 @@ -import sqlite3 +"""PureNote — a retro/CRT local note app built with Kivy. + +100% offline: notes live in a local SQLite file. Refreshed from the original +single-textbox version to a multi-note app with titles, dates, search, tags, +markdown preview and export/backup. +""" + +import os +import time + +from kivy.config import Config + from kivy.app import App -from kivy.uix.button import Button -from kivy.uix.textinput import TextInput +from kivy.core.window import Window +from kivy.clock import Clock +from kivy.lang import Builder +from kivy.properties import ( + BooleanProperty, + ColorProperty, + NumericProperty, + ListProperty, + 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.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) - - def select_note(self, touch): - if self.selected: - return False +from kivy.uix.screenmanager import Screen, ScreenManager, SlideTransition +from kivy.uix.textinput import TextInput + +import theme +import md +from db import NoteStore + + +def _user_dir(): + """Writable base dir: app user_data_dir on Android, cwd elsewhere.""" + try: + from android.storage import app_storage_path # noqa + + return app_storage_path() + except Exception: + return os.path.dirname(os.path.abspath(__file__)) + + +def _fmt_date(ts): + if not ts: + return "" + return time.strftime("%d/%m/%Y %H:%M", time.localtime(ts)) + + +def _snippet(body, n=90): + text = " ".join((body or "").split()) + return text[:n] + ("..." if len(text) > n else "") + + +# ------------------------------------------------------------ styled widgets +# Defined in Python so `accent` always has a real (iterable) default before +# the kv canvas rules reference accent[0]/[1]/[2]. +class RetroButton(ButtonBehavior, Label): + accent = ColorProperty(theme.GREEN) + + +class GhostButton(ButtonBehavior, Label): + accent = ColorProperty(theme.GREEN) + + +class IconButton(ButtonBehavior, Label): + accent = ColorProperty(theme.GREEN) + + +class RetroInput(TextInput): + pass + + +# ---------------------------------------------------------------- list screen +class NoteCard(ButtonBehavior, BoxLayout): + note_id = NumericProperty(0) + title = StringProperty() + snippet = StringProperty() + meta = StringProperty() + pinned = BooleanProperty(False) + + def on_release(self): + App.get_running_app().open_editor(self.note_id) + + def toggle_pin(self): + app = App.get_running_app() + app.store.set_pinned(self.note_id, not self.pinned) + app.refresh_list() + + +class TagChip(ButtonBehavior, BoxLayout): + tag = StringProperty() + active = BooleanProperty(False) + + def on_release(self): + app = App.get_running_app() + app.set_tag_filter("" if self.active else self.tag) + + +class ListScreen(Screen): + query = StringProperty("") + active_tag = StringProperty("") + + +# -------------------------------------------------------------- editor screen +class EditorScreen(Screen): + note_id = NumericProperty(0) # 0 == new note + preview = BooleanProperty(False) + pinned = BooleanProperty(False) + counts = StringProperty("") + preview_markup = StringProperty("") + + def load(self, note_id): + self.note_id = note_id or 0 + self.preview = False + app = App.get_running_app() + if note_id: + n = app.store.get(note_id) + self.ids.title.text = n["title"] + self.ids.body.text = n["body"] + self.ids.tags.text = n["tags"] + self.pinned = bool(n["pinned"]) 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 - - - 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 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 NoteApp(App): - - def __init__(self, **kwargs): - super(NoteApp, self).__init__(**kwargs) - # Initialize the selected note to None - self.selected_note = None + self.ids.title.text = "" + self.ids.body.text = "" + self.ids.tags.text = "" + self.pinned = False + self.update_counts() + + def update_counts(self, *_): + body = self.ids.body.text + chars = len(body) + words = len(body.split()) + self.counts = f"{words} parole / {chars} caratteri" + + def toggle_preview(self): + if not self.preview: + self.preview_markup = md.to_markup(self.ids.body.text) + self.preview = not self.preview + + def toggle_pin(self): + self.pinned = not self.pinned + + def save(self): + app = App.get_running_app() + title = self.ids.title.text.strip() + body = self.ids.body.text + tags = ",".join( + t.strip() for t in self.ids.tags.text.split(",") if t.strip() + ) + if not title and not body.strip(): + app.go_list() + return + if not title: + title = NoteStore._title_from_body(body) + if self.note_id: + app.store.update(self.note_id, title, body, tags, self.pinned) + else: + self.note_id = app.store.add(title, body, tags, self.pinned) + app.go_list() + + def delete(self): + app = App.get_running_app() + if self.note_id: + app.confirm( + f'Eliminare "{self.ids.title.text or "questa nota"}"?', + self._do_delete, + ) + else: + app.go_list() + + def _do_delete(self): + app = App.get_running_app() + app.store.delete(self.note_id) + app.go_list() + + def export(self): + App.get_running_app().export_note( + self.ids.title.text, self.ids.body.text + ) + + +class RootManager(ScreenManager): + pass + + +# ----------------------------------------------------------------------- app +class PureNoteApp(App): + tags = ListProperty([]) 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() + self.title = "PureNote" + Window.clearcolor = theme.BG + os.environ.setdefault( + "PURENOTE_DB", os.path.join(_user_dir(), "notes.db") + ) + # Keep the bundled legacy/seed db if the user dir is empty. + seed = os.path.join(os.path.dirname(__file__), "notes.db") + target = os.environ["PURENOTE_DB"] + if seed != target and not os.path.exists(target) and os.path.exists(seed): + import shutil + + shutil.copyfile(seed, target) + self.store = NoteStore(target) + root = Builder.load_file( + os.path.join(os.path.dirname(__file__), "purenote_ui.kv") + ) + Clock.schedule_once(lambda *_: self.refresh_list(), 0) + return root + + # ----------------------------------------------------------- navigation + @property + def sm(self): + return self.root + + def open_editor(self, note_id): + self.sm.transition = SlideTransition(direction="left") + self.sm.get_screen("editor").load(note_id) + self.sm.current = "editor" + + def new_note(self): + self.open_editor(0) + + def go_list(self): + self.sm.transition = SlideTransition(direction="right") + self.sm.current = "list" + self.refresh_list() + + # -------------------------------------------------------------- list data + def set_query(self, text): + self.sm.get_screen("list").query = text + self.refresh_list() + + def set_tag_filter(self, tag): + self.sm.get_screen("list").active_tag = tag + self.refresh_list() + + def refresh_list(self): + ls = self.sm.get_screen("list") + notes = self.store.list(query=ls.query, tag=ls.active_tag) + ls.ids.rv.data = [ + { + "note_id": n["id"], + "title": n["title"] or "Senza titolo", + "snippet": _snippet(n["body"]), + "meta": _fmt_date(n["updated_at"]), + "pinned": bool(n["pinned"]), + } + for n in notes + ] + ls.ids.empty.opacity = 0 if notes else 1 + self._rebuild_tags(ls) + + def _rebuild_tags(self, ls): + bar = ls.ids.tagbar + bar.clear_widgets() + tags = self.store.all_tags() + for t in tags: + chip = TagChip(tag=t, active=(t == ls.active_tag)) + bar.add_widget(chip) + ls.ids.tagscroll.opacity = 1 if tags else 0 + ls.ids.tagscroll.height = "36dp" if tags else 0 + + # ----------------------------------------------------------------- export + def export_note(self, title, body): + safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip() + safe = (safe or "nota")[:40] + out_dir = os.path.join(_user_dir(), "PureNote_export") + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, f"{safe}.md") + content = f"# {title}\n\n{body}\n" if title else (body or "") + with open(path, "w", encoding="utf-8") as f: + f.write(content) + self._share_or_toast(path, content) + + def export_db(self): + import shutil + + out_dir = os.path.join(_user_dir(), "PureNote_export") + os.makedirs(out_dir, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + path = os.path.join(out_dir, f"purenote_backup_{stamp}.db") + shutil.copyfile(self.store.path, path) + self.toast(f"Backup salvato:\n{path}") + + def _share_or_toast(self, path, content): + try: + from plyer import share # noqa + + share.share(text=content) + return + except Exception: + pass + self.toast(f"Esportato in:\n{path}") + + # ------------------------------------------------------------- ui helpers + def toast(self, message): + from kivy.uix.popup import Popup + from kivy.uix.label import Label + + Popup( + title="PureNote", + size_hint=(0.85, 0.4), + content=Label(text=message, font_name=theme.FONT_MONO), + ).open() + + def confirm(self, message, on_yes): + from kivy.uix.popup import Popup + from kivy.uix.label import Label + from kivy.uix.button import Button + + box = BoxLayout(orientation="vertical", spacing=10, padding=10) + box.add_widget(Label(text=message, font_name=theme.FONT_MONO)) + row = BoxLayout(size_hint_y=None, height="48dp", spacing=10) + popup = Popup(title="Conferma", content=box, size_hint=(0.85, 0.45)) + + def yes(*_): + popup.dismiss() + on_yes() + + row.add_widget(Button(text="Annulla", on_release=popup.dismiss)) + row.add_widget(Button(text="Elimina", on_release=yes)) + box.add_widget(row) + popup.open() + + def open_backup_menu(self): + from kivy.uix.popup import Popup + from kivy.uix.button import Button + + box = BoxLayout(orientation="vertical", spacing=10, padding=10) + popup = Popup(title="Backup & Export", content=box, size_hint=(0.85, 0.5)) + + def do(fn): + popup.dismiss() + fn() + + b1 = Button(text="Esporta backup DB", font_name=theme.FONT_TITLE) + b1.bind(on_release=lambda *_: do(self.export_db)) + box.add_widget(b1) + b2 = Button(text="Chiudi", font_name=theme.FONT_TITLE) + b2.bind(on_release=popup.dismiss) + box.add_widget(b2) + popup.open() + + +if __name__ == "__main__": + PureNoteApp().run() diff --git a/md.py b/md.py new file mode 100644 index 0000000..6648b14 --- /dev/null +++ b/md.py @@ -0,0 +1,84 @@ +"""Tiny Markdown -> Kivy markup converter for the preview pane. + +Supports the subset that makes sense on a phone note: headings, bold, +italic, inline code, bullet lists and ``- [ ]`` / ``- [x]`` checklists. +Kivy Labels render a BBCode-like markup when ``markup=True``. +""" + +import re + +from theme import GREEN, MAGENTA, AMBER, TEXT_DIM + + +def _rgba_to_hex(rgba): + r, g, b, _ = rgba + return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + +_GREEN = _rgba_to_hex(GREEN) +_MAGENTA = _rgba_to_hex(MAGENTA) +_AMBER = _rgba_to_hex(AMBER) +_DIM = _rgba_to_hex(TEXT_DIM) + + +def _escape(text): + # Kivy markup uses [ ] for tags; escape literals so they render. + return text.replace("&", "&").replace("[", "&bl;").replace("]", "&br;") + + +def _content(text): + """Escape literals then apply inline markdown -> markup.""" + return _inline(_escape(text)) + + +def _inline(text): + """Convert inline markdown (already escaped) to Kivy markup.""" + # `code` + text = re.sub(r"`([^`]+)`", rf"[color=#{_AMBER}]\1[/color]", text) + # **bold** or __bold__ + text = re.sub(r"(\*\*|__)(.+?)\1", r"[b]\2[/b]", text) + # *italic* or _italic_ + text = re.sub(r"(?[/color] {_content(m.group(1))}") + continue + + out.append(_content(raw)) + + return "\n".join(out) diff --git a/notes.db b/notes.db index 0b274b892c1b58086f28dbd491c9b5901ffa674c..f3dfb0613477326f48420d6c7b1c509db1960106 100644 GIT binary patch literal 12288 zcmeI2!EVz)5J2rD0%D24E#iPQD2KLAh$?a5TBjHk;W{BWh^VT{#@<8=o88o22g(r; zH?DjdpMu1VaiRz{K`OlEj9(jWiJoQK54WnK+#0W=z7`3*%j4+ZzM66Cg)J}6I$T9QpZdR*9DHsJQWM9k6pJ7q2LOSy<@5K zqy=iFu{hSbGHu6cG~iHY4Cc8OFkvZo?n6n>iYX#V3&{AVyYzGwEJdKNeCSg&m zCrnBPW0t0@NSsNd(aK#Nv>UW^mr;bo#AYmgljyg~b>Y5g-CYfCvx)B0vO)01+SpMBu+7aKkRwtIK~begaB=0^a}t delta 195 zcmZojXmFSy%__^lz`!(7!Jc21K`)t+7szAe-^;+ice9{^KR<^fBb&ImIAikUEIuV; zE(K(uk(XbRs!): + font_name: t.FONT_TITLE + font_size: t.FS_BTN + color: t.TEXT_INV + size_hint_y: None + height: dp(46) + canvas.before: + Color: + rgba: self.accent if self.state == 'normal' else (self.accent[0], self.accent[1], self.accent[2], 0.7) + RoundedRectangle: + pos: self.pos + size: self.size + radius: [t.RADIUS] + +: + font_name: t.FONT_TITLE + font_size: t.FS_BTN + color: self.accent + size_hint_y: None + height: dp(46) + canvas.before: + Color: + rgba: (self.accent[0], self.accent[1], self.accent[2], 0.12 if self.state == 'down' else 0.0) + RoundedRectangle: + pos: self.pos + size: self.size + radius: [t.RADIUS] + Color: + rgba: self.accent + Line: + rounded_rectangle: (self.x, self.y, self.width, self.height, t.RADIUS) + width: 1.1 + +: + font_name: t.FONT_MONO + font_size: '20sp' + color: self.accent if self.state == 'normal' else t.TEXT + size_hint: None, None + size: dp(44), dp(44) + +: + background_color: t.BG_INPUT + foreground_color: t.TEXT + cursor_color: t.GREEN + selection_color: (t.GREEN[0], t.GREEN[1], t.GREEN[2], 0.3) + hint_text_color: t.TEXT_DIM + font_name: t.FONT_MONO + padding: [dp(10), dp(10)] + background_normal: '' + background_active: '' + +# ======================================================================== root +RootManager: + ListScreen: + name: 'list' + EditorScreen: + name: 'editor' + +# ==================================================================== top bars +: + size_hint_y: None + height: dp(58) + padding: [dp(12), dp(6)] + spacing: dp(8) + canvas.before: + Color: + rgba: t.BG_PANEL + Rectangle: + pos: self.pos + size: self.size + Color: + rgba: t.GREEN + Rectangle: + pos: self.x, self.y + size: self.width, dp(1.5) + +# ==================================================================== note card +: + orientation: 'horizontal' + size_hint_y: None + height: dp(86) + padding: [dp(12), dp(8)] + spacing: dp(8) + canvas.before: + Color: + rgba: t.BG_PANEL + RoundedRectangle: + pos: self.x + dp(6), self.y + dp(4) + size: self.width - dp(12), self.height - dp(8) + radius: [t.RADIUS] + Color: + rgba: (t.AMBER if self.pinned else t.GREEN) + RoundedRectangle: + pos: self.x + dp(6), self.y + dp(4) + size: dp(4), self.height - dp(8) + radius: [t.RADIUS] + BoxLayout: + orientation: 'vertical' + spacing: dp(2) + Label: + text: root.title + font_name: t.FONT_TITLE + font_size: t.FS_BODY + color: t.TEXT + halign: 'left' + valign: 'middle' + shorten: True + shorten_from: 'right' + text_size: self.width, None + size_hint_y: None + height: dp(24) + Label: + text: root.snippet + font_name: t.FONT_MONO + font_size: t.FS_META + color: t.TEXT_DIM + halign: 'left' + valign: 'top' + shorten: True + text_size: self.width, self.height + Label: + text: root.meta + font_name: t.FONT_MONO + font_size: t.FS_META + color: t.GREEN_DIM + halign: 'left' + valign: 'middle' + text_size: self.width, None + size_hint_y: None + height: dp(16) + IconButton: + accent: t.AMBER if root.pinned else t.TEXT_DIM + text: '*' + on_release: root.toggle_pin() + +# ===================================================================== tag chip +: + orientation: 'horizontal' + size_hint_x: None + width: self.minimum_width + padding: [dp(10), dp(4)] + canvas.before: + Color: + rgba: t.GREEN if root.active else (t.GREEN[0], t.GREEN[1], t.GREEN[2], 0.12) + RoundedRectangle: + pos: self.pos + size: self.size + radius: [dp(12)] + Label: + id: lbl + text: '#' + root.tag + font_name: t.FONT_MONO + font_size: t.FS_META + color: t.TEXT_INV if root.active else t.GREEN + size_hint_x: None + width: self.texture_size[0] + +# =================================================================== list screen +: + BoxLayout: + orientation: 'vertical' + TopBar: + Image: + source: 'logo.png' + size_hint_x: None + width: dp(40) + allow_stretch: True + keep_ratio: True + Label: + text: 'PURENOTE' + font_name: t.FONT_TITLE + font_size: '24sp' + color: t.GREEN + halign: 'left' + valign: 'middle' + text_size: self.size + IconButton: + accent: t.MAGENTA + text: '=' + on_release: app.open_backup_menu() + # search + BoxLayout: + size_hint_y: None + height: dp(48) + padding: [dp(12), dp(6)] + RetroInput: + id: search + hint_text: 'cerca...' + multiline: False + on_text: app.set_query(self.text) + # tag filter bar + ScrollView: + id: tagscroll + size_hint_y: None + height: dp(36) + do_scroll_y: False + BoxLayout: + id: tagbar + orientation: 'horizontal' + size_hint_x: None + width: self.minimum_width + spacing: dp(6) + padding: [dp(12), 0] + # list + FloatLayout: + RecycleView: + id: rv + viewclass: 'NoteCard' + RecycleBoxLayout: + default_size: None, dp(86) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + Label: + id: empty + opacity: 0 + text: 'nessuna nota\\n\\npremi + NUOVA per iniziare' + font_name: t.FONT_MONO + color: t.TEXT_DIM + halign: 'center' + pos_hint: {'center_x': .5, 'center_y': .5} + RetroButton: + text: '+ NUOVA' + font_name: t.FONT_MONO + accent: t.GREEN + size_hint: None, None + size: dp(130), dp(50) + pos_hint: {'right': 0.97, 'y': 0.03} + on_release: app.new_note() + +# ================================================================= editor screen +: + BoxLayout: + orientation: 'vertical' + TopBar: + IconButton: + accent: t.GREEN + text: '<' + on_release: app.go_list() + Label: + text: 'NOTA' if root.note_id else 'NUOVA' + font_name: t.FONT_TITLE + font_size: '20sp' + color: t.GREEN + halign: 'left' + valign: 'middle' + text_size: self.size + IconButton: + accent: t.AMBER if root.pinned else t.TEXT_DIM + text: '*' + on_release: root.toggle_pin() + IconButton: + accent: t.MAGENTA if root.preview else t.TEXT_DIM + text: 'o' + on_release: root.toggle_preview() + IconButton: + accent: t.GREEN + text: '^' + on_release: root.export() + IconButton: + accent: t.DANGER + text: 'x' + on_release: root.delete() + BoxLayout: + orientation: 'vertical' + padding: [dp(12), dp(8)] + spacing: dp(8) + RetroInput: + id: title + hint_text: 'titolo' + font_name: t.FONT_TITLE + font_size: '20sp' + multiline: False + size_hint_y: None + height: dp(48) + RetroInput: + id: tags + hint_text: 'tag separati da virgola' + font_size: t.FS_META + multiline: False + size_hint_y: None + height: dp(38) + # body / preview swap + RetroInput: + id: body + hint_text: 'scrivi... (markdown supportato)' + font_size: t.FS_BODY + opacity: 0 if root.preview else 1 + disabled: root.preview + size_hint_y: 0 if root.preview else 1 + on_text: root.update_counts() + ScrollView: + opacity: 1 if root.preview else 0 + size_hint_y: 1 if root.preview else None + height: 0 + do_scroll_x: False + StackLayout: + size_hint_y: None + height: self.minimum_height + Label: + text: root.preview_markup + markup: True + font_name: t.FONT_MONO + font_size: t.FS_BODY + color: t.TEXT + halign: 'left' + valign: 'top' + size_hint_x: 1 + size_hint_y: None + height: self.texture_size[1] + text_size: self.width, None + padding: [dp(6), dp(6)] + BoxLayout: + size_hint_y: None + height: dp(54) + padding: [dp(12), dp(4)] + spacing: dp(10) + Label: + text: root.counts + font_name: t.FONT_MONO + font_size: t.FS_META + color: t.TEXT_DIM + halign: 'left' + valign: 'middle' + text_size: self.size + RetroButton: + text: 'SALVA' + accent: t.GREEN + size_hint_x: None + width: dp(120) + on_release: root.save() diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..dabddce --- /dev/null +++ b/theme.py @@ -0,0 +1,36 @@ +"""Central retro/CRT theme for PureNote. + +Colors are RGBA tuples in 0..1. The palette evokes an old phosphor terminal: +near-black background, phosphor green as the primary accent and a magenta +secondary, echoing the original app's green/magenta buttons. +""" + +# ------------------------------------------------------------------ palette +BG = (0.043, 0.055, 0.047, 1) # #0b0e0c deep CRT black-green +BG_PANEL = (0.078, 0.094, 0.082, 1) # #141815 raised panels / cards +BG_INPUT = (0.027, 0.035, 0.030, 1) # #070907 text fields +LINE = (0.16, 0.22, 0.17, 1) # subtle separators / borders + +GREEN = (0.25, 0.95, 0.45, 1) # #40f273 primary phosphor accent +GREEN_DIM = (0.18, 0.55, 0.30, 1) # dimmed green for secondary text +MAGENTA = (0.95, 0.25, 0.75, 1) # #f240bf secondary accent +AMBER = (1.0, 0.78, 0.30, 1) # #ffc74d warnings / pin highlight + +TEXT = (0.82, 0.95, 0.84, 1) # main readable text (soft green-white) +TEXT_DIM = (0.45, 0.58, 0.48, 1) # metadata, placeholders +TEXT_INV = (0.043, 0.055, 0.047, 1) # text on bright (green) backgrounds +DANGER = (0.95, 0.35, 0.35, 1) # delete + +# ------------------------------------------------------------------- fonts +FONT_MONO = "GS.ttf" # Green Screen — body / terminal feel +FONT_TITLE = "TT.ttf" # 806 Typography — headers / buttons + +# -------------------------------------------------------------------- sizes +FS_TITLE = "22sp" +FS_BODY = "17sp" +FS_META = "12sp" +FS_BTN = "16sp" + +RADIUS = 6 +PAD = "12dp" +GAP = "8dp" From 9922bc134592d5a30af8ff9340e0232f201f5d05 Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 15 Jun 2026 21:15:59 +0200 Subject: [PATCH 02/29] CI: auto-accept Android SDK licenses to fix aidl/build-tools failure Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-apk.yml | 8 ++++++++ buildozer.spec | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 51620bb..eb93382 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -48,6 +48,14 @@ jobs: restore-keys: | buildozer-local- + - name: Pre-accept Android SDK licenses + run: | + LIC_DIR="$HOME/.buildozer/android/platform/android-sdk/licenses" + mkdir -p "$LIC_DIR" + printf '\n8933bad161af4178b1185d1a37fbf41ea5269c55\nd56f5187479451eabf01fb78af6dfcb131a6481e\n24333f8a63b6825ea9c5514f83c2829b004d1fee\n' > "$LIC_DIR/android-sdk-license" + printf '\n84831b9409646a918e30573bab4c9c91346d8abd\n504667f4c0de7af1a06de9f4b1727b84351f2910\n' > "$LIC_DIR/android-sdk-preview-license" + printf '\n859f317696f67ef3d7f30a50a5560e7834b43903\n' > "$LIC_DIR/android-sdk-arm-dbt-license" + - name: Build debug APK run: buildozer android debug diff --git a/buildozer.spec b/buildozer.spec index 5cf88fd..91aa6c7 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -41,6 +41,9 @@ fullscreen = 0 # Android specific # +# (bool) If True, then automatically accept SDK license +android.accept_sdk_license = True + # (list) Permissions android.permissions = WRITE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE From aebde27fea0e6f2bd36c8c34bb22d9324eacfa8a Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 15 Jun 2026 21:21:52 +0200 Subject: [PATCH 03/29] CI: let buildozer download SDK itself; rely on accept_sdk_license; bump cache key Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-apk.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index eb93382..c0b0804 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -36,25 +36,17 @@ jobs: uses: actions/cache@v4 with: path: ~/.buildozer - key: buildozer-global-${{ hashFiles('buildozer.spec') }} + key: buildozer-global-v2-${{ hashFiles('buildozer.spec') }} restore-keys: | - buildozer-global- + buildozer-global-v2- - name: Cache buildozer local directory uses: actions/cache@v4 with: path: .buildozer - key: buildozer-local-${{ hashFiles('buildozer.spec') }} + key: buildozer-local-v2-${{ hashFiles('buildozer.spec') }} restore-keys: | - buildozer-local- - - - name: Pre-accept Android SDK licenses - run: | - LIC_DIR="$HOME/.buildozer/android/platform/android-sdk/licenses" - mkdir -p "$LIC_DIR" - printf '\n8933bad161af4178b1185d1a37fbf41ea5269c55\nd56f5187479451eabf01fb78af6dfcb131a6481e\n24333f8a63b6825ea9c5514f83c2829b004d1fee\n' > "$LIC_DIR/android-sdk-license" - printf '\n84831b9409646a918e30573bab4c9c91346d8abd\n504667f4c0de7af1a06de9f4b1727b84351f2910\n' > "$LIC_DIR/android-sdk-preview-license" - printf '\n859f317696f67ef3d7f30a50a5560e7834b43903\n' > "$LIC_DIR/android-sdk-arm-dbt-license" + buildozer-local-v2- - name: Build debug APK run: buildozer android debug From 4d89ba5571e4d054ce746b5e2dc592cc6d6f7fad Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 15 Jun 2026 22:11:39 +0200 Subject: [PATCH 04/29] CI: use JDK 17 so Android Gradle Plugin (compileSdk 35) applies Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-apk.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index c0b0804..6ca4533 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -17,6 +17,12 @@ jobs: with: python-version: "3.11" + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Install system dependencies run: | sudo apt-get update From 47dd9a236f3d77c53a6cef1e89f3f83cecd5231a Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 15 Jun 2026 22:27:36 +0200 Subject: [PATCH 05/29] CI: cache across failed builds (restore/save if:always), build arm64 only for debug Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-apk.yml | 33 ++++++++++++++++++++------------- buildozer.spec | 3 ++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 6ca4533..f600059 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -38,26 +38,33 @@ jobs: pip install --upgrade buildozer pip install "cython==0.29.36" - - name: Cache buildozer global directory (SDK/NDK) - uses: actions/cache@v4 + # Restore the most recent cache for this spec (SDK/NDK + compiled bundle). + # A unique save key per run + prefix restore-keys means progress + # accumulates even across failed builds. + - name: Restore buildozer cache + uses: actions/cache/restore@v4 with: - path: ~/.buildozer - key: buildozer-global-v2-${{ hashFiles('buildozer.spec') }} + path: | + ~/.buildozer + .buildozer + key: buildozer-v3-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} restore-keys: | - buildozer-global-v2- - - - name: Cache buildozer local directory - uses: actions/cache@v4 - with: - path: .buildozer - key: buildozer-local-v2-${{ hashFiles('buildozer.spec') }} - restore-keys: | - buildozer-local-v2- + buildozer-v3-${{ hashFiles('buildozer.spec') }}- - name: Build debug APK run: buildozer android debug + - name: Save buildozer cache + if: always() + uses: actions/cache/save@v4 + with: + path: | + ~/.buildozer + .buildozer + key: buildozer-v3-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} + - name: Upload APK artifact + if: success() uses: actions/upload-artifact@v4 with: name: purenote-debug-apk diff --git a/buildozer.spec b/buildozer.spec index 91aa6c7..5d5d6ce 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -58,7 +58,8 @@ android.minapi = 24 # android.ndk = 25b # (list) The Android archs to build for -android.archs = arm64-v8a, armeabi-v7a +# Debug: arm64-v8a only (faster). Re-add armeabi-v7a for the release AAB. +android.archs = arm64-v8a # (bool) enables Android auto backup feature (Android API >= 23) android.allow_backup = True From 32aa1445ebf999f61a7341c04b844086b8d4517e Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 15 Jun 2026 23:04:38 +0200 Subject: [PATCH 06/29] Fix Android 15 edge-to-edge: pad content for status/nav bar insets Co-Authored-By: Claude Opus 4.8 --- main.py | 28 ++++++++++++++++++++++++++++ purenote_ui.kv | 2 ++ 2 files changed, 30 insertions(+) diff --git a/main.py b/main.py index e40dac0..cb7fc17 100644 --- a/main.py +++ b/main.py @@ -188,10 +188,15 @@ class RootManager(ScreenManager): # ----------------------------------------------------------------------- app class PureNoteApp(App): tags = ListProperty([]) + # Safe-area insets (px). Android 15 (target API 35) draws edge-to-edge, + # so we pad content away from the status bar (top) and nav bar (bottom). + inset_top = NumericProperty(0) + inset_bottom = NumericProperty(0) def build(self): self.title = "PureNote" Window.clearcolor = theme.BG + self._read_android_insets() os.environ.setdefault( "PURENOTE_DB", os.path.join(_user_dir(), "notes.db") ) @@ -209,6 +214,29 @@ def build(self): Clock.schedule_once(lambda *_: self.refresh_list(), 0) return root + def _read_android_insets(self): + """Read system bar heights (px) so content clears the status/nav bars. + + Android 15 enforces edge-to-edge for apps targeting API 35; without + this the top bar hides under the clock and the Save button/FAB hide + under the gesture/nav bar. No-op on desktop. + """ + try: + from jnius import autoclass + + activity = autoclass("org.kivy.android.PythonActivity").mActivity + res = activity.getResources() + + def dim(name): + rid = res.getIdentifier(name, "dimen", "android") + return res.getDimensionPixelSize(rid) if rid > 0 else 0 + + self.inset_top = dim("status_bar_height") + self.inset_bottom = dim("navigation_bar_height") + except Exception: + self.inset_top = 0 + self.inset_bottom = 0 + # ----------------------------------------------------------- navigation @property def sm(self): diff --git a/purenote_ui.kv b/purenote_ui.kv index e590001..b3c10d9 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -163,6 +163,7 @@ RootManager: : BoxLayout: orientation: 'vertical' + padding: [0, app.inset_top, 0, app.inset_bottom] TopBar: Image: source: 'logo.png' @@ -237,6 +238,7 @@ RootManager: : BoxLayout: orientation: 'vertical' + padding: [0, app.inset_top, 0, app.inset_bottom] TopBar: IconButton: accent: t.GREEN From d4794c07f45953de63690dcc1eadacbd99cde588 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 16 Jun 2026 10:22:39 +0200 Subject: [PATCH 07/29] Editor redesign: robust body/preview, auto-save, icon+label toolbar, native share - Fix Android body field unreachable: body & preview now share a FloatLayout cell with the inactive one disabled (no touch overlap, body keeps full height) - Auto-save while typing (debounced) and on back; manual Save icon with flash - Toolbar buttons as icon + small label (Salva/Fissa/Anteprima/Condividi/Elimina) - Export now opens the native Android share sheet (ACTION_SEND) - Android back key returns to list (and saves) instead of closing the app - Wrap toast popup text; compact word/char counter - CI: persist ~/.android debug keystore so updates install without uninstall Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-apk.yml | 5 + main.py | 164 +++++++++++++++++++++++++------- purenote_ui.kv | 160 ++++++++++++++++++++----------- 3 files changed, 236 insertions(+), 93 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index f600059..7e4166a 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -41,11 +41,15 @@ jobs: # Restore the most recent cache for this spec (SDK/NDK + compiled bundle). # A unique save key per run + prefix restore-keys means progress # accumulates even across failed builds. + # ~/.android holds the debug.keystore: persisting it keeps the signing + # key stable across builds, so new debug APKs install over the previous + # one without "signatures do not match" / uninstall. - name: Restore buildozer cache uses: actions/cache/restore@v4 with: path: | ~/.buildozer + ~/.android .buildozer key: buildozer-v3-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} restore-keys: | @@ -60,6 +64,7 @@ jobs: with: path: | ~/.buildozer + ~/.android .buildozer key: buildozer-v3-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} diff --git a/main.py b/main.py index cb7fc17..96938d8 100644 --- a/main.py +++ b/main.py @@ -72,6 +72,13 @@ class RetroInput(TextInput): pass +class ToolButton(ButtonBehavior, BoxLayout): + """Toolbar button rendered as an icon glyph over a small text label.""" + glyph = StringProperty() + label = StringProperty() + accent = ColorProperty(theme.GREEN) + + # ---------------------------------------------------------------- list screen class NoteCard(ButtonBehavior, BoxLayout): note_id = NumericProperty(0) @@ -110,8 +117,10 @@ class EditorScreen(Screen): pinned = BooleanProperty(False) counts = StringProperty("") preview_markup = StringProperty("") + _loading = False def load(self, note_id): + self._loading = True self.note_id = note_id or 0 self.preview = False app = App.get_running_app() @@ -127,22 +136,25 @@ def load(self, note_id): self.ids.tags.text = "" self.pinned = False self.update_counts() + self._loading = False - def update_counts(self, *_): - body = self.ids.body.text - chars = len(body) - words = len(body.split()) - self.counts = f"{words} parole / {chars} caratteri" + # ------------------------------------------------------------- text/save + def on_text_changed(self): + """Bound to every input's on_text: refresh counts and debounce save.""" + self.update_counts() + if not self._loading: + Clock.unschedule(self._autosave) + Clock.schedule_once(self._autosave, 1.0) - def toggle_preview(self): - if not self.preview: - self.preview_markup = md.to_markup(self.ids.body.text) - self.preview = not self.preview + def _autosave(self, *_): + self._persist() - def toggle_pin(self): - self.pinned = not self.pinned + def update_counts(self, *_): + body = self.ids.body.text + self.counts = f"{len(body.split())} par · {len(body)} car" - def save(self): + def _persist(self): + """Write the note to the DB without navigating. Returns True if saved.""" app = App.get_running_app() title = self.ids.title.text.strip() body = self.ids.body.text @@ -150,33 +162,52 @@ def save(self): t.strip() for t in self.ids.tags.text.split(",") if t.strip() ) if not title and not body.strip(): - app.go_list() - return + return False if not title: title = NoteStore._title_from_body(body) if self.note_id: app.store.update(self.note_id, title, body, tags, self.pinned) else: self.note_id = app.store.add(title, body, tags, self.pinned) - app.go_list() + return True + + def manual_save(self): + Clock.unschedule(self._autosave) + if self._persist(): + App.get_running_app().flash("Salvato") + else: + App.get_running_app().flash("Nota vuota") + + # ---------------------------------------------------------------- actions + def toggle_preview(self): + if not self.preview: + self.preview_markup = md.to_markup(self.ids.body.text) + self.preview = not self.preview + + def toggle_pin(self): + self.pinned = not self.pinned + if self.note_id: + self._persist() def delete(self): app = App.get_running_app() + Clock.unschedule(self._autosave) if self.note_id: app.confirm( f'Eliminare "{self.ids.title.text or "questa nota"}"?', self._do_delete, ) else: - app.go_list() + app.go_list(save=False) def _do_delete(self): app = App.get_running_app() app.store.delete(self.note_id) - app.go_list() + self.note_id = 0 + app.go_list(save=False) def export(self): - App.get_running_app().export_note( + App.get_running_app().share_note( self.ids.title.text, self.ids.body.text ) @@ -211,9 +242,19 @@ def build(self): root = Builder.load_file( os.path.join(os.path.dirname(__file__), "purenote_ui.kv") ) + Window.bind(on_keyboard=self._on_key) Clock.schedule_once(lambda *_: self.refresh_list(), 0) return root + def _on_key(self, window, key, *args): + # Android back / Esc: from the editor, save and return to the list + # (consume the event); from the list, let it close the app. + if key == 27: + if self.sm.current == "editor": + self.go_list() + return True + return False + def _read_android_insets(self): """Read system bar heights (px) so content clears the status/nav bars. @@ -250,7 +291,11 @@ def open_editor(self, note_id): def new_note(self): self.open_editor(0) - def go_list(self): + def go_list(self, save=True): + editor = self.sm.get_screen("editor") + Clock.unschedule(editor._autosave) + if save: + editor._persist() self.sm.transition = SlideTransition(direction="right") self.sm.current = "list" self.refresh_list() @@ -290,8 +335,40 @@ def _rebuild_tags(self, ls): ls.ids.tagscroll.opacity = 1 if tags else 0 ls.ids.tagscroll.height = "36dp" if tags else 0 - # ----------------------------------------------------------------- export - def export_note(self, title, body): + # --------------------------------------------------------- share / export + def share_note(self, title, body): + """Open the native Android share sheet with the note as plain text.""" + text = (f"{title}\n\n{body}".strip() if title else (body or "")).strip() + if not text: + self.flash("Nota vuota") + return + try: + from jnius import autoclass, cast + + Intent = autoclass("android.content.Intent") + String = autoclass("java.lang.String") + activity = autoclass("org.kivy.android.PythonActivity").mActivity + + intent = Intent() + intent.setAction(Intent.ACTION_SEND) + intent.setType("text/plain") + intent.putExtra( + Intent.EXTRA_TEXT, cast("java.lang.CharSequence", String(text)) + ) + if title: + intent.putExtra( + Intent.EXTRA_SUBJECT, + cast("java.lang.CharSequence", String(title)), + ) + chooser = Intent.createChooser( + intent, cast("java.lang.CharSequence", String("Condividi nota")) + ) + activity.startActivity(chooser) + except Exception: + # Desktop fallback: write a .md file and report where. + self._export_note_file(title, body) + + def _export_note_file(self, title, body): safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip() safe = (safe or "nota")[:40] out_dir = os.path.join(_user_dir(), "PureNote_export") @@ -300,7 +377,7 @@ def export_note(self, title, body): content = f"# {title}\n\n{body}\n" if title else (body or "") with open(path, "w", encoding="utf-8") as f: f.write(content) - self._share_or_toast(path, content) + self.toast(f"Esportato in:\n{path}") def export_db(self): import shutil @@ -312,26 +389,39 @@ def export_db(self): shutil.copyfile(self.store.path, path) self.toast(f"Backup salvato:\n{path}") - def _share_or_toast(self, path, content): - try: - from plyer import share # noqa - - share.share(text=content) - return - except Exception: - pass - self.toast(f"Esportato in:\n{path}") - # ------------------------------------------------------------- ui helpers + def flash(self, message): + """Brief, non-blocking confirmation that fades out.""" + from kivy.uix.label import Label + from kivy.animation import Animation + + lbl = Label( + text=message, + font_name=theme.FONT_TITLE, + font_size=theme.FS_BTN, + color=(theme.GREEN[0], theme.GREEN[1], theme.GREEN[2], 1), + size_hint=(None, None), + size=(Window.width, "40dp"), + ) + lbl.center_x = Window.width / 2 + lbl.y = Window.height * 0.12 + self.inset_bottom + Window.add_widget(lbl) + anim = Animation(opacity=0, duration=1.3, t="in_quad") + anim.bind(on_complete=lambda *a: Window.remove_widget(lbl)) + anim.start(lbl) + def toast(self, message): from kivy.uix.popup import Popup from kivy.uix.label import Label - Popup( - title="PureNote", - size_hint=(0.85, 0.4), - content=Label(text=message, font_name=theme.FONT_MONO), - ).open() + content = Label( + text=message, + font_name=theme.FONT_MONO, + halign="center", + valign="middle", + ) + content.bind(size=lambda lbl, s: setattr(lbl, "text_size", s)) + Popup(title="PureNote", size_hint=(0.85, 0.4), content=content).open() def confirm(self, message, on_yes): from kivy.uix.popup import Popup diff --git a/purenote_ui.kv b/purenote_ui.kv index b3c10d9..a07aa59 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -53,6 +53,36 @@ background_normal: '' background_active: '' +: + orientation: 'vertical' + spacing: dp(1) + padding: [dp(2), dp(6)] + canvas.before: + Color: + rgba: (self.accent[0], self.accent[1], self.accent[2], 0.14 if self.state == 'down' else 0.0) + RoundedRectangle: + pos: self.pos + size: self.size + radius: [t.RADIUS] + Label: + text: root.glyph + font_name: t.FONT_MONO + font_size: '19sp' + color: root.accent + size_hint_y: None + height: dp(24) + Label: + text: root.label + font_name: t.FONT_MONO + font_size: '10sp' + color: root.accent + size_hint_y: None + height: dp(14) + halign: 'center' + valign: 'middle' + text_size: self.width, None + shorten: True + # ======================================================================== root RootManager: ListScreen: @@ -239,6 +269,7 @@ RootManager: BoxLayout: orientation: 'vertical' padding: [0, app.inset_top, 0, app.inset_bottom] + # --- title bar: back + screen name + live counter TopBar: IconButton: accent: t.GREEN @@ -252,22 +283,52 @@ RootManager: halign: 'left' valign: 'middle' text_size: self.size - IconButton: + Label: + text: root.counts + font_name: t.FONT_MONO + font_size: t.FS_META + color: t.TEXT_DIM + halign: 'right' + valign: 'middle' + text_size: self.size + # --- action bar: clear icon + label buttons + BoxLayout: + size_hint_y: None + height: dp(52) + padding: [dp(6), dp(2)] + spacing: dp(4) + canvas.before: + Color: + rgba: t.BG_PANEL + Rectangle: + pos: self.pos + size: self.size + ToolButton: + glyph: 'v' + label: 'Salva' + accent: t.GREEN + on_release: root.manual_save() + ToolButton: + glyph: '*' + label: 'Fissa' accent: t.AMBER if root.pinned else t.TEXT_DIM - text: '*' on_release: root.toggle_pin() - IconButton: + ToolButton: + glyph: 'o' + label: 'Modifica' if root.preview else 'Anteprima' accent: t.MAGENTA if root.preview else t.TEXT_DIM - text: 'o' on_release: root.toggle_preview() - IconButton: + ToolButton: + glyph: '^' + label: 'Condividi' accent: t.GREEN - text: '^' on_release: root.export() - IconButton: + ToolButton: + glyph: 'x' + label: 'Elimina' accent: t.DANGER - text: 'x' on_release: root.delete() + # --- inputs BoxLayout: orientation: 'vertical' padding: [dp(12), dp(8)] @@ -280,6 +341,7 @@ RootManager: multiline: False size_hint_y: None height: dp(48) + on_text: root.on_text_changed() RetroInput: id: tags hint_text: 'tag separati da virgola' @@ -287,52 +349,38 @@ RootManager: multiline: False size_hint_y: None height: dp(38) - # body / preview swap - RetroInput: - id: body - hint_text: 'scrivi... (markdown supportato)' - font_size: t.FS_BODY - opacity: 0 if root.preview else 1 - disabled: root.preview - size_hint_y: 0 if root.preview else 1 - on_text: root.update_counts() - ScrollView: - opacity: 1 if root.preview else 0 - size_hint_y: 1 if root.preview else None - height: 0 - do_scroll_x: False - StackLayout: - size_hint_y: None - height: self.minimum_height - Label: - text: root.preview_markup - markup: True - font_name: t.FONT_MONO - font_size: t.FS_BODY - color: t.TEXT - halign: 'left' - valign: 'top' - size_hint_x: 1 + on_text: root.on_text_changed() + # body / preview live in one cell; the inactive one is disabled + # (disabled widgets don't receive touches) so there is no overlap. + FloatLayout: + RetroInput: + id: body + hint_text: 'scrivi... (markdown supportato)' + font_size: t.FS_BODY + size_hint: 1, 1 + pos_hint: {'x': 0, 'y': 0} + opacity: 0 if root.preview else 1 + disabled: root.preview + on_text: root.on_text_changed() + ScrollView: + size_hint: 1, 1 + pos_hint: {'x': 0, 'y': 0} + opacity: 1 if root.preview else 0 + disabled: not root.preview + do_scroll_x: False + StackLayout: size_hint_y: None - height: self.texture_size[1] - text_size: self.width, None - padding: [dp(6), dp(6)] - BoxLayout: - size_hint_y: None - height: dp(54) - padding: [dp(12), dp(4)] - spacing: dp(10) - Label: - text: root.counts - font_name: t.FONT_MONO - font_size: t.FS_META - color: t.TEXT_DIM - halign: 'left' - valign: 'middle' - text_size: self.size - RetroButton: - text: 'SALVA' - accent: t.GREEN - size_hint_x: None - width: dp(120) - on_release: root.save() + height: self.minimum_height + Label: + text: root.preview_markup + markup: True + font_name: t.FONT_MONO + font_size: t.FS_BODY + color: t.TEXT + halign: 'left' + valign: 'top' + size_hint_x: 1 + size_hint_y: None + height: self.texture_size[1] + text_size: self.width, None + padding: [dp(6), dp(6)] From d37431575d96573387879c04ddec9b4945d0e020 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 16 Jun 2026 10:46:41 +0200 Subject: [PATCH 08/29] Fix body input unreachable on Android: remove preview pane from tree while editing A disabled overlay still swallows touches on Android, so the body TextInput never got focus. Now the preview ScrollView is added to the body cell only in preview mode and removed while editing; verified body.focus=True after a touch. Also fix middot tofu in the counter (use /). Co-Authored-By: Claude Opus 4.8 --- main.py | 26 +++++++++++++++++++++----- purenote_ui.kv | 4 ++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 96938d8..f2c29d8 100644 --- a/main.py +++ b/main.py @@ -122,7 +122,7 @@ class EditorScreen(Screen): def load(self, note_id): self._loading = True self.note_id = note_id or 0 - self.preview = False + self._set_preview(False) app = App.get_running_app() if note_id: n = app.store.get(note_id) @@ -151,7 +151,7 @@ def _autosave(self, *_): def update_counts(self, *_): body = self.ids.body.text - self.counts = f"{len(body.split())} par · {len(body)} car" + self.counts = f"{len(body.split())} par / {len(body)} car" def _persist(self): """Write the note to the DB without navigating. Returns True if saved.""" @@ -179,10 +179,26 @@ def manual_save(self): App.get_running_app().flash("Nota vuota") # ---------------------------------------------------------------- actions - def toggle_preview(self): - if not self.preview: + def _set_preview(self, on): + """Add/remove the preview pane from the tree. + + Keeping the preview ScrollView out of the widget tree while editing is + the only reliable way to stop it from intercepting touches meant for + the body input (a disabled overlay still swallows them on Android). + """ + area = self.ids.body_area + pv = self.ids.preview_box + if on: self.preview_markup = md.to_markup(self.ids.body.text) - self.preview = not self.preview + if pv.parent is None: + area.add_widget(pv) + else: + if pv.parent is not None: + area.remove_widget(pv) + self.preview = on + + def toggle_preview(self): + self._set_preview(not self.preview) def toggle_pin(self): self.pinned = not self.pinned diff --git a/purenote_ui.kv b/purenote_ui.kv index a07aa59..dec0ea5 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -353,6 +353,7 @@ RootManager: # body / preview live in one cell; the inactive one is disabled # (disabled widgets don't receive touches) so there is no overlap. FloatLayout: + id: body_area RetroInput: id: body hint_text: 'scrivi... (markdown supportato)' @@ -363,10 +364,9 @@ RootManager: disabled: root.preview on_text: root.on_text_changed() ScrollView: + id: preview_box size_hint: 1, 1 pos_hint: {'x': 0, 'y': 0} - opacity: 1 if root.preview else 0 - disabled: not root.preview do_scroll_x: False StackLayout: size_hint_y: None From 6b0feaf0817630b5c7cb664d68e73f8c5eedc9d4 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 16 Jun 2026 20:23:54 +0200 Subject: [PATCH 09/29] Fix crash on reopening editor: collapse preview pane to 0x0 instead of detaching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the preview ScrollView from the tree let it be GC'd (self.ids are weak proxies), so the next open hit ReferenceError. Keep it in the tree but size it 0x0 while editing — a 0-size widget can't intercept the body's touches, and the body stays focusable. Verified: 5 open/close cycles with forced GC, no crash; body.focus=True after touch; preview still renders. Co-Authored-By: Claude Opus 4.8 --- main.py | 15 ++++----------- purenote_ui.kv | 7 ++++++- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/main.py b/main.py index f2c29d8..a34df04 100644 --- a/main.py +++ b/main.py @@ -180,21 +180,14 @@ def manual_save(self): # ---------------------------------------------------------------- actions def _set_preview(self, on): - """Add/remove the preview pane from the tree. + """Toggle preview mode. - Keeping the preview ScrollView out of the widget tree while editing is - the only reliable way to stop it from intercepting touches meant for - the body input (a disabled overlay still swallows them on Android). + The preview pane stays in the tree but collapses to size 0 while + editing (a 0-size widget can't intercept touches meant for the body; + a merely-disabled full-size overlay still swallows them on Android). """ - area = self.ids.body_area - pv = self.ids.preview_box if on: self.preview_markup = md.to_markup(self.ids.body.text) - if pv.parent is None: - area.add_widget(pv) - else: - if pv.parent is not None: - area.remove_widget(pv) self.preview = on def toggle_preview(self): diff --git a/purenote_ui.kv b/purenote_ui.kv index dec0ea5..406da7c 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -365,8 +365,13 @@ RootManager: on_text: root.on_text_changed() ScrollView: id: preview_box - size_hint: 1, 1 + # full-size only in preview; collapses to 0x0 while editing + # so it cannot intercept touches meant for the body input + size_hint: (1, 1) if root.preview else (None, None) + size: 0, 0 pos_hint: {'x': 0, 'y': 0} + opacity: 1 if root.preview else 0 + disabled: not root.preview do_scroll_x: False StackLayout: size_hint_y: None From f7f507b5c08a97e929d425e1aac9ac7affd9a924 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 23 Jun 2026 11:54:44 +0200 Subject: [PATCH 10/29] Add interactive checklists, trash/undo, runtime seed, and release AAB workflow - Editor: tappable `- [ ]` checkboxes in the preview (md.to_blocks + CheckRow) toggle the matching source line in place and auto-save - Trash: soft-delete with an undo snackbar, a trash bin (restore / delete / empty) and a `trashed` column with idempotent migration - Seed: stop shipping a binary notes.db; build the welcome note at runtime when the DB is empty, and untrack notes.db - CI: build-release.yml produces a signed AAB (both ABIs) from keystore secrets; RELEASE.md documents the setup Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-release.yml | 102 ++++++++++++++ .gitignore | 1 + README.md | 4 +- RELEASE.md | 56 ++++++++ buildozer.spec | 9 +- db.py | 51 ++++++- main.py | 201 +++++++++++++++++++++++++--- md.py | 109 ++++++++++----- notes.db | Bin 12288 -> 0 bytes purenote_ui.kv | 98 ++++++++++++-- 10 files changed, 557 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/build-release.yml create mode 100644 RELEASE.md delete mode 100644 notes.db diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..8db277f --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,102 @@ +name: Build PureNote Release AAB + +# Signed Android App Bundle for the Play Store. +# Triggered manually or by pushing a version tag (e.g. v2.1). +# Requires these repo secrets for a SIGNED bundle (otherwise the AAB is +# unsigned and only good for validating the build): +# ANDROID_KEYSTORE_B64 base64 of the upload keystore (.keystore/.jks) +# ANDROID_KEYSTORE_PASSWORD keystore (store) password +# ANDROID_KEY_ALIAS key alias inside the keystore +# ANDROID_KEY_PASSWORD password for that alias +# See RELEASE.md for how to generate the keystore and set the secrets. + +on: + workflow_dispatch: + push: + tags: ["v*"] + +jobs: + build-release: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + git zip unzip openjdk-17-jdk python3-pip autoconf libtool \ + pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev \ + libtinfo5 cmake libffi-dev libssl-dev build-essential \ + ccache libltdl-dev + + - name: Install buildozer + run: | + python -m pip install --upgrade pip + pip install --upgrade buildozer + pip install "cython==0.29.36" + + # Reuse the SDK/NDK that the debug workflow already downloaded + # (same buildozer-v3 prefix); the release adds armeabi-v7a on top. + - name: Restore buildozer cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.buildozer + ~/.android + .buildozer + key: buildozer-release-v1-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} + restore-keys: | + buildozer-release-v1-${{ hashFiles('buildozer.spec') }}- + buildozer-v3-${{ hashFiles('buildozer.spec') }}- + + - name: Build release AAB + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + # Release ships both ABIs (Play serves the right one per device). + sed -i 's/^android.archs = .*/android.archs = arm64-v8a,armeabi-v7a/' buildozer.spec + if [ -n "$ANDROID_KEYSTORE_B64" ]; then + echo "$ANDROID_KEYSTORE_B64" | base64 -d > "$HOME/release.keystore" + export P4A_RELEASE_KEYSTORE="$HOME/release.keystore" + export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" + export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" + export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" + echo "Keystore present -> building a SIGNED release AAB" + else + echo "::warning::No ANDROID_KEYSTORE_B64 secret set — building an UNSIGNED AAB (not uploadable to Play)." + fi + buildozer android release + + - name: Save buildozer cache + if: always() + uses: actions/cache/save@v4 + with: + path: | + ~/.buildozer + ~/.android + .buildozer + key: buildozer-release-v1-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} + + - name: Upload AAB artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: purenote-release-aab + path: bin/*.aab + if-no-files-found: error diff --git a/.gitignore b/.gitignore index a6dbbb2..e6a4ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.pyc *.bak *.orig +notes.db notes.db.orig.bak PureNote_export/ .buildozer/ diff --git a/README.md b/README.md index bc25ad2..c9c0088 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ A retro/CRT note-taking app for Android, built with Python and Kivy. 100% local - Multi-note with titles, tags and creation/modification dates - Live search across titles and bodies - Tag filtering -- Markdown editor with live preview (headings, bold, italic, code, bullet & checklists) +- Markdown editor with live preview (headings, bold, italic, code, bullets) +- Interactive checklists: tap `- [ ]` items in the preview to tick them +- Trash with undo: deleted notes are recoverable, with an undo action and a trash bin (restore / empty) - Pin important notes to the top - Word / character counter - Export a note as Markdown and full database backup diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c60c011 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,56 @@ +# Pubblicare PureNote (Release AAB firmato) + +Il workflow `.github/workflows/build-release.yml` produce l'**Android App Bundle +(.aab)** firmato da caricare sul Play Store. Si avvia manualmente +(*Actions → Build PureNote Release AAB → Run workflow*) o pushando un tag +versione (`git tag v2.1 && git push origin v2.1`). + +## 1. Genera il keystore (una sola volta) + +Crea la chiave di firma in locale e **conservala al sicuro** (se la perdi non +puoi più aggiornare l'app sul Play Store): + +``` +keytool -genkey -v \ + -keystore purenote-release.keystore \ + -alias purenote \ + -keyalg RSA -keysize 2048 -validity 10000 +``` + +Ti chiederà una password (store) e i tuoi dati. Annota: +- password del keystore +- alias (`purenote`) +- password dell'alias (può coincidere con quella del keystore) + +## 2. Imposta i secret su GitHub + +Converti il keystore in base64: + +``` +base64 -i purenote-release.keystore | pbcopy # macOS, copia negli appunti +``` + +Poi su GitHub: *Settings → Secrets and variables → Actions → New repository +secret* e crea: + +| Secret | Valore | +|---|---| +| `ANDROID_KEYSTORE_B64` | il base64 del keystore (passo sopra) | +| `ANDROID_KEYSTORE_PASSWORD` | password del keystore | +| `ANDROID_KEY_ALIAS` | `purenote` | +| `ANDROID_KEY_PASSWORD` | password dell'alias | + +> Senza questi secret il workflow gira lo stesso ma produce un AAB **non +> firmato**, utile solo a validare la build (non caricabile sul Play Store). + +## 3. Avvia la build e scarica l'AAB + +Lancia il workflow; al termine scarica l'artifact `purenote-release-aab` +(contiene `bin/*.aab`) e caricalo nella Play Console. + +## Note +- Il release builda **entrambe le architetture** (`arm64-v8a` + `armeabi-v7a`); + il Play Store serve poi la ABI giusta a ciascun dispositivo. La build di + debug resta solo `arm64-v8a` per velocità. +- Incrementa `version` in `buildozer.spec` ad ogni release (la Play Console + rifiuta due upload con lo stesso version code). diff --git a/buildozer.spec b/buildozer.spec index 5d5d6ce..a19b0e6 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -13,7 +13,8 @@ package.domain = bbnss.test source.dir = . # (list) Source files to include (let empty to include all the files) -source.include_exts = py,png,jpg,ttf,kv,db +# No `db`: the notes database is created/seeded at runtime, never shipped. +source.include_exts = py,png,jpg,ttf,kv # (list) Source files to exclude source.exclude_exts = bak,orig @@ -64,6 +65,12 @@ android.archs = arm64-v8a # (bool) enables Android auto backup feature (Android API >= 23) android.allow_backup = True +# (str) Format used to package the app for release mode (aab for the Play Store). +android.release_artifact = aab + +# (str) Format used to package the app for debug mode (apk for sideloading). +android.debug_artifact = apk + [buildozer] # (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) diff --git a/db.py b/db.py index 0286145..84dcdb3 100644 --- a/db.py +++ b/db.py @@ -42,11 +42,21 @@ def _init_schema(self): )""" ) self.conn.commit() + self._ensure_columns() self._migrate_legacy() def _table_columns(self, table): return [r[1] for r in self.conn.execute(f"PRAGMA table_info({table})")] + def _ensure_columns(self): + """Add columns introduced after the first v2 schema (idempotent).""" + cols = self._table_columns("notes") + if "trashed" not in cols: + self.conn.execute( + "ALTER TABLE notes ADD COLUMN trashed INTEGER NOT NULL DEFAULT 0" + ) + self.conn.commit() + def _stash_legacy_table(self): cols = self._table_columns("notes") # New schema already in place -> nothing to stash. @@ -104,18 +114,46 @@ def set_pinned(self, note_id, pinned): ) self.conn.commit() + def trash(self, note_id): + """Soft-delete: move a note to the trash (recoverable).""" + self.conn.execute( + "UPDATE notes SET trashed=1, updated_at=? WHERE id=?", + (_now(), note_id), + ) + self.conn.commit() + + def restore(self, note_id): + self.conn.execute( + "UPDATE notes SET trashed=0, updated_at=? WHERE id=?", + (_now(), note_id), + ) + self.conn.commit() + def delete(self, note_id): + """Permanently remove a note.""" self.conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) self.conn.commit() + def empty_trash(self): + self.conn.execute("DELETE FROM notes WHERE trashed=1") + self.conn.commit() + + def count_trashed(self): + return self.conn.execute( + "SELECT COUNT(*) FROM notes WHERE trashed=1" + ).fetchone()[0] + + def is_empty(self): + return self.conn.execute("SELECT COUNT(*) FROM notes").fetchone()[0] == 0 + def get(self, note_id): row = self.conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() return dict(row) if row else None # ----------------------------------------------------------------- queries - def list(self, query="", tag=""): - sql = "SELECT * FROM notes" - clauses, params = [], [] + def list(self, query="", tag="", trashed=0): + clauses = ["trashed = ?"] + params = [int(trashed)] if query: clauses.append("(title LIKE ? OR body LIKE ?)") like = f"%{query}%" @@ -123,14 +161,15 @@ def list(self, query="", tag=""): if tag: clauses.append("(',' || replace(tags,' ','') || ',') LIKE ?") params.append(f"%,{tag},%") - if clauses: - sql += " WHERE " + " AND ".join(clauses) + sql = "SELECT * FROM notes WHERE " + " AND ".join(clauses) sql += " ORDER BY pinned DESC, updated_at DESC" return [dict(r) for r in self.conn.execute(sql, params).fetchall()] def all_tags(self): tags = set() - for (raw,) in self.conn.execute("SELECT tags FROM notes WHERE tags != ''"): + for (raw,) in self.conn.execute( + "SELECT tags FROM notes WHERE tags != '' AND trashed = 0" + ): for t in raw.split(","): t = t.strip() if t: diff --git a/main.py b/main.py index a34df04..8ad076d 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ """ import os +import re import time from kivy.config import Config @@ -13,7 +14,9 @@ from kivy.app import App from kivy.core.window import Window from kivy.clock import Clock +from kivy.factory import Factory from kivy.lang import Builder +from kivy.metrics import dp from kivy.properties import ( BooleanProperty, ColorProperty, @@ -96,6 +99,24 @@ def toggle_pin(self): app.refresh_list() +class UndoBar(BoxLayout): + """Transient bottom bar with an undo action; `on_undo` set per use.""" + msg = StringProperty("") + + def on_undo(self): + pass + + +class CheckRow(ButtonBehavior, BoxLayout): + """A tappable checklist row in the preview; toggles the source line.""" + checked = BooleanProperty(False) + label_markup = StringProperty("") + line = NumericProperty(0) + + def on_release(self): + App.get_running_app().sm.get_screen("editor").toggle_check(self.line) + + class TagChip(ButtonBehavior, BoxLayout): tag = StringProperty() active = BooleanProperty(False) @@ -116,7 +137,6 @@ class EditorScreen(Screen): preview = BooleanProperty(False) pinned = BooleanProperty(False) counts = StringProperty("") - preview_markup = StringProperty("") _loading = False def load(self, note_id): @@ -187,9 +207,38 @@ def _set_preview(self, on): a merely-disabled full-size overlay still swallows them on Android). """ if on: - self.preview_markup = md.to_markup(self.ids.body.text) + self._build_preview() self.preview = on + def _build_preview(self): + """Render the body into the preview column as tappable blocks.""" + col = self.ids.preview_col + col.clear_widgets() + for blk in md.to_blocks(self.ids.body.text): + if blk["type"] == "check": + col.add_widget( + CheckRow( + checked=blk["checked"], + label_markup=blk["label"], + line=blk["line"], + ) + ) + else: + col.add_widget(Factory.PreviewLabel(text=blk["text"])) + + def toggle_check(self, line): + """Flip a `- [ ]`/`- [x]` checkbox on the given body line in place.""" + lines = self.ids.body.text.split("\n") + if not (0 <= line < len(lines)): + return + m = re.match(r"^(\s*[-*]\s+\[)( |x|X)(\].*)$", lines[line]) + if not m: + return + lines[line] = m.group(1) + (" " if m.group(2) != " " else "x") + m.group(3) + self.ids.body.text = "\n".join(lines) + self._build_preview() + self._persist() + def toggle_preview(self): self._set_preview(not self.preview) @@ -199,22 +248,18 @@ def toggle_pin(self): self._persist() def delete(self): + """Soft-delete: move to trash and offer an undo (recoverable).""" app = App.get_running_app() Clock.unschedule(self._autosave) if self.note_id: - app.confirm( - f'Eliminare "{self.ids.title.text or "questa nota"}"?', - self._do_delete, - ) + nid = self.note_id + app.store.trash(nid) + self.note_id = 0 + app.go_list(save=False) + app.snack_undo("Nota nel cestino", lambda: app.restore_note(nid)) else: app.go_list(save=False) - def _do_delete(self): - app = App.get_running_app() - app.store.delete(self.note_id) - self.note_id = 0 - app.go_list(save=False) - def export(self): App.get_running_app().share_note( self.ids.title.text, self.ids.body.text @@ -240,14 +285,8 @@ def build(self): os.environ.setdefault( "PURENOTE_DB", os.path.join(_user_dir(), "notes.db") ) - # Keep the bundled legacy/seed db if the user dir is empty. - seed = os.path.join(os.path.dirname(__file__), "notes.db") - target = os.environ["PURENOTE_DB"] - if seed != target and not os.path.exists(target) and os.path.exists(seed): - import shutil - - shutil.copyfile(seed, target) - self.store = NoteStore(target) + self.store = NoteStore(os.environ["PURENOTE_DB"]) + self._seed_if_empty() root = Builder.load_file( os.path.join(os.path.dirname(__file__), "purenote_ui.kv") ) @@ -255,6 +294,29 @@ def build(self): Clock.schedule_once(lambda *_: self.refresh_list(), 0) return root + def _seed_if_empty(self): + """On a fresh install (no DB rows) create a single welcome note. + + Replaces shipping a binary seed `notes.db` in the APK: the note is + built in code so it always matches the current feature set and the + repo stays free of a tracked database. + """ + if not self.store.is_empty(): + return + body = ( + "# Benvenuto in PureNote\n\n" + "App di note **100% offline**: i tuoi appunti restano sul telefono.\n\n" + "## Cosa puoi fare\n" + "- Scrivi in **markdown** e tocca *Anteprima*\n" + "- Tocca le caselle qui sotto per spuntarle:\n" + "- [ ] provare la *ricerca* in alto\n" + "- [x] aprire questa nota\n" + "- [ ] fissare una nota con *Fissa*\n\n" + "## Tag\n" + "Aggiungi tag separati da virgola per filtrare le note.\n" + ) + self.store.add("Benvenuto in PureNote", body, "guida, info", 1) + def _on_key(self, window, key, *args): # Android back / Esc: from the editor, save and return to the list # (consume the event); from the list, let it close the app. @@ -344,6 +406,11 @@ def _rebuild_tags(self, ls): ls.ids.tagscroll.opacity = 1 if tags else 0 ls.ids.tagscroll.height = "36dp" if tags else 0 + def restore_note(self, note_id): + self.store.restore(note_id) + self.refresh_list() + self.flash("Ripristinata") + # --------------------------------------------------------- share / export def share_note(self, title, body): """Open the native Android share sheet with the note as plain text.""" @@ -419,6 +486,29 @@ def flash(self, message): anim.bind(on_complete=lambda *a: Window.remove_widget(lbl)) anim.start(lbl) + def snack_undo(self, message, on_undo, timeout=4): + """Bottom bar with an ANNULLA action that auto-dismisses.""" + self._undo_dismiss() + bar = Factory.UndoBar(msg=message) + bar.on_undo = lambda: self._undo_do(on_undo) + bar.width = Window.width - dp(24) + bar.x = dp(12) + bar.y = self.inset_bottom + dp(12) + Window.add_widget(bar) + self._undo_bar = bar + Clock.schedule_once(self._undo_dismiss, timeout) + + def _undo_dismiss(self, *_): + Clock.unschedule(self._undo_dismiss) + bar = getattr(self, "_undo_bar", None) + if bar is not None: + Window.remove_widget(bar) + self._undo_bar = None + + def _undo_do(self, cb): + self._undo_dismiss() + cb() + def toast(self, message): from kivy.uix.popup import Popup from kivy.uix.label import Label @@ -465,11 +555,82 @@ def do(fn): b1 = Button(text="Esporta backup DB", font_name=theme.FONT_TITLE) b1.bind(on_release=lambda *_: do(self.export_db)) box.add_widget(b1) + n = self.store.count_trashed() + b_trash = Button( + text=f"Cestino ({n})" if n else "Cestino", + font_name=theme.FONT_TITLE, + ) + b_trash.bind(on_release=lambda *_: do(self.open_trash)) + box.add_widget(b_trash) b2 = Button(text="Chiudi", font_name=theme.FONT_TITLE) b2.bind(on_release=popup.dismiss) box.add_widget(b2) popup.open() + def open_trash(self): + from kivy.uix.popup import Popup + from kivy.uix.scrollview import ScrollView + from kivy.uix.button import Button + + trashed = self.store.list(trashed=1) + box = BoxLayout(orientation="vertical", spacing=8, padding=10) + popup = Popup(title="Cestino", content=box, size_hint=(0.94, 0.85)) + + if not trashed: + box.add_widget(Label(text="Cestino vuoto", font_name=theme.FONT_MONO)) + else: + scroll = ScrollView() + col = BoxLayout(orientation="vertical", size_hint_y=None, spacing=6) + col.bind(minimum_height=col.setter("height")) + for note in trashed: + row = BoxLayout(size_hint_y=None, height="46dp", spacing=6) + lbl = Label( + text=note["title"] or "Senza titolo", + font_name=theme.FONT_MONO, + halign="left", + valign="middle", + shorten=True, + ) + lbl.bind(size=lambda l, s: setattr(l, "text_size", s)) + row.add_widget(lbl) + rb = Button( + text="Ripristina", font_name=theme.FONT_TITLE, + size_hint_x=None, width="120dp", + ) + xb = Button( + text="Elimina", font_name=theme.FONT_TITLE, + size_hint_x=None, width="100dp", + ) + rb.bind(on_release=lambda *_, i=note["id"]: ( + self.store.restore(i), popup.dismiss(), + self.refresh_list(), self.open_trash(), + )) + xb.bind(on_release=lambda *_, i=note["id"]: ( + self.store.delete(i), popup.dismiss(), self.open_trash(), + )) + row.add_widget(rb) + row.add_widget(xb) + col.add_widget(row) + scroll.add_widget(col) + box.add_widget(scroll) + + bottom = BoxLayout(size_hint_y=None, height="46dp", spacing=8) + if trashed: + eb = Button(text="Svuota cestino", font_name=theme.FONT_TITLE) + eb.bind(on_release=lambda *_: ( + popup.dismiss(), + self.confirm( + "Svuotare il cestino? L'azione non è reversibile.", + lambda: (self.store.empty_trash(), self.flash("Cestino svuotato")), + ), + )) + bottom.add_widget(eb) + cb = Button(text="Chiudi", font_name=theme.FONT_TITLE) + cb.bind(on_release=popup.dismiss) + bottom.add_widget(cb) + box.add_widget(bottom) + popup.open() + if __name__ == "__main__": PureNoteApp().run() diff --git a/md.py b/md.py index 6648b14..690c7fc 100644 --- a/md.py +++ b/md.py @@ -43,42 +43,87 @@ def _inline(text): return text +# A checklist line: optional leading space, - or *, then [ ], [x] or [X]. +CHECK_RE = re.compile(r"[-*]\s+\[( |x|X)\]\s+(.*)") + + +def _line_markup(raw): + """Convert a single non-checklist line to Kivy markup.""" + stripped = raw.strip() + + # Headings + m = re.match(r"(#{1,3})\s+(.*)", stripped) + if m: + level = len(m.group(1)) + size = {1: 26, 2: 22, 3: 19}[level] + return ( + f"[size={size}sp][b][color=#{_GREEN}]{_content(m.group(2))}" + f"[/color][/b][/size]" + ) + + # Bullet list + m = re.match(r"[-*]\s+(.*)", stripped) + if m: + return f" [color=#{_MAGENTA}]>[/color] {_content(m.group(1))}" + + return _content(raw) + + +def _check_label(checked, text): + """Markup for the text part of a checklist item (dimmed when checked).""" + body = _content(text) + return f"[color=#{_DIM}]{body}[/color]" if checked else body + + def to_markup(source): + """Render the whole source to a single markup string (static preview).""" out = [] - for raw in (source or "").splitlines(): - stripped = raw.strip() - - # Headings - m = re.match(r"(#{1,3})\s+(.*)", stripped) + for raw in (source or "").split("\n"): + m = CHECK_RE.match(raw.strip()) if m: - level = len(m.group(1)) - size = {1: 26, 2: 22, 3: 19}[level] - out.append( - f"[size={size}sp][b][color=#{_GREEN}]{_content(m.group(2))}" - f"[/color][/b][/size]" + checked = m.group(1).lower() == "x" + box = ( + f"[color=#{_GREEN}]&bl;x&br;[/color]" + if checked + else f"[color=#{_DIM}]&bl; &br;[/color]" ) - continue + out.append(f" {box} {_check_label(checked, m.group(2))}") + else: + out.append(_line_markup(raw)) + return "\n".join(out) - # Checklist items: - [ ] / - [x] - m = re.match(r"[-*]\s+\[( |x|X)\]\s+(.*)", stripped) - if m: - checked = m.group(1).lower() == "x" - if checked: - box = f"[color=#{_GREEN}]&bl;x&br;[/color]" - else: - box = f"[color=#{_DIM}]&bl; &br;[/color]" - body = _content(m.group(2)) - if checked: - body = f"[color=#{_DIM}]{body}[/color]" - out.append(f" {box} {body}") - continue - - # Bullet list - m = re.match(r"[-*]\s+(.*)", stripped) - if m: - out.append(f" [color=#{_MAGENTA}]>[/color] {_content(m.group(1))}") - continue - out.append(_content(raw)) +def to_blocks(source): + """Split the source into render blocks for an interactive preview. - return "\n".join(out) + Returns a list of dicts. Consecutive non-checklist lines are merged into + one ``{"type": "markup", "text": ...}`` block; every checklist line becomes + its own ``{"type": "check", "checked": bool, "label": markup, "line": idx}`` + block, where ``line`` is the 0-based index into ``source.split("\\n")`` so a + tap can toggle that exact line back in the body text. + """ + blocks = [] + buf = [] + + def flush(): + if buf: + blocks.append({"type": "markup", "text": "\n".join(buf)}) + buf.clear() + + for idx, raw in enumerate((source or "").split("\n")): + m = CHECK_RE.match(raw.strip()) + if m: + flush() + checked = m.group(1).lower() == "x" + blocks.append( + { + "type": "check", + "checked": checked, + "label": _check_label(checked, m.group(2)), + "line": idx, + } + ) + else: + buf.append(_line_markup(raw)) + flush() + return blocks diff --git a/notes.db b/notes.db deleted file mode 100644 index f3dfb0613477326f48420d6c7b1c509db1960106..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI2!EVz)5J2rD0%D24E#iPQD2KLAh$?a5TBjHk;W{BWh^VT{#@<8=o88o22g(r; zH?DjdpMu1VaiRz{K`OlEj9(jWiJoQK54WnK+#0W=z7`3*%j4+ZzM66Cg)J}6I$T9QpZdR*9DHsJQWM9k6pJ7q2LOSy<@5K zqy=iFu{hSbGHu6cG~iHY4Cc8OFkvZo?n6n>iYX#V3&{AVyYzGwEJdKNeCSg&m zCrnBPW0t0@NSsNd(aK#Nv>UW^mr;bo#AYmgljyg~b>Y5g-CYfCvx)B0vO)01+SpMBu+7aKkRwtIK~begaB=0^a}t diff --git a/purenote_ui.kv b/purenote_ui.kv index 406da7c..3359661 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -83,6 +83,85 @@ text_size: self.width, None shorten: True +: + markup: True + font_name: t.FONT_MONO + font_size: t.FS_BODY + color: t.TEXT + halign: 'left' + valign: 'top' + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + padding: [dp(6), dp(2)] + +: + orientation: 'horizontal' + size_hint_y: None + height: max(dp(30), lbl.texture_size[1] + dp(10)) + padding: [dp(6), dp(2)] + spacing: dp(6) + canvas.before: + Color: + rgba: (t.GREEN[0], t.GREEN[1], t.GREEN[2], 0.12 if self.state == 'down' else 0.0) + Rectangle: + pos: self.pos + size: self.size + Label: + text: '[x]' if root.checked else '[ ]' + font_name: t.FONT_MONO + font_size: t.FS_BODY + color: t.GREEN if root.checked else t.TEXT_DIM + size_hint_x: None + width: dp(36) + halign: 'left' + valign: 'top' + text_size: self.width, None + Label: + id: lbl + text: root.label_markup + markup: True + font_name: t.FONT_MONO + font_size: t.FS_BODY + color: t.TEXT + halign: 'left' + valign: 'top' + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + +: + size_hint: None, None + height: dp(48) + padding: [dp(12), dp(4)] + spacing: dp(8) + canvas.before: + Color: + rgba: t.BG_PANEL + RoundedRectangle: + pos: self.pos + size: self.size + radius: [t.RADIUS] + Color: + rgba: t.GREEN + Line: + rounded_rectangle: (self.x, self.y, self.width, self.height, t.RADIUS) + width: 1.1 + Label: + text: root.msg + font_name: t.FONT_MONO + font_size: t.FS_META + color: t.TEXT + halign: 'left' + valign: 'middle' + text_size: self.size + GhostButton: + text: 'ANNULLA' + accent: t.AMBER + size_hint_x: None + width: dp(100) + on_release: root.on_undo() + # ======================================================================== root RootManager: ListScreen: @@ -373,19 +452,10 @@ RootManager: opacity: 1 if root.preview else 0 disabled: not root.preview do_scroll_x: False - StackLayout: + BoxLayout: + id: preview_col + orientation: 'vertical' size_hint_y: None height: self.minimum_height - Label: - text: root.preview_markup - markup: True - font_name: t.FONT_MONO - font_size: t.FS_BODY - color: t.TEXT - halign: 'left' - valign: 'top' - size_hint_x: 1 - size_hint_y: None - height: self.texture_size[1] - text_size: self.width, None - padding: [dp(6), dp(6)] + padding: [dp(6), dp(6)] + spacing: dp(2) From b60d35c0457f06405399305ffe7a3fad5eff8b45 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 23 Jun 2026 16:29:51 +0200 Subject: [PATCH 11/29] Pin release versionCode to 10215 for the first Play Store upload of v2 The previously published build was versionName 0.4 / versionCode 10214. Set android.numeric_version=10215 so the signed AAB clears Play's strictly-increasing versionCode requirement; keep versionName 2.0. Co-Authored-By: Claude Opus 4.8 --- buildozer.spec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/buildozer.spec b/buildozer.spec index a19b0e6..65a4105 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -20,9 +20,13 @@ source.include_exts = py,png,jpg,ttf,kv source.exclude_exts = bak,orig source.exclude_dirs = .venv,images,__pycache__,PureNote_export -# (str) Application versioning +# (str) Application versioning (versionName shown to users) version = 2.0 +# (int) Version code: Play Store requires it to strictly increase every upload. +# Previous published versionCode was 10214, so this must stay > 10214. +android.numeric_version = 10215 + # (list) Application requirements requirements = python3,kivy==2.3.1,plyer From 534f6535d1bff20d8b7c94d44f4931a2bbc5287b Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 26 Oct 2026 19:44:11 +0100 Subject: [PATCH 12/29] Simplify PureNote editor and add reliable local autosave and portable backups --- .github/actions/android-build/action.yml | 34 + .github/workflows/build-apk.yml | 102 +-- .github/workflows/build-release.yml | 114 +-- .github/workflows/test.yml | 34 + .gitignore | 8 + README.md | 135 ++- RELEASE.md | 86 +- android_window.py | 55 ++ backup.py | 72 ++ buildozer.spec | 77 +- db.py | 318 +++---- documents.py | 135 +++ editor.py | 86 ++ images/purenote-editor.png | Bin 0 -> 17405 bytes images/purenote-list.png | Bin 0 -> 14522 bytes main.py | 1017 ++++++++++------------ md.py | 129 --- purenote_ui.kv | 575 ++++-------- pyproject.toml | 11 + requirements-build.txt | 3 + requirements-dev.txt | 3 + stats.py | 14 + tests/conftest.py | 10 + tests/test_backup_stats.py | 107 +++ tests/test_editor.py | 124 +++ tests/test_store.py | 183 ++++ tests/ui_smoke.py | 165 ++++ theme.py | 46 +- 28 files changed, 2111 insertions(+), 1532 deletions(-) create mode 100644 .github/actions/android-build/action.yml create mode 100644 .github/workflows/test.yml create mode 100644 android_window.py create mode 100644 backup.py create mode 100644 documents.py create mode 100644 editor.py create mode 100644 images/purenote-editor.png create mode 100644 images/purenote-list.png delete mode 100644 md.py create mode 100644 pyproject.toml create mode 100644 requirements-build.txt create mode 100644 requirements-dev.txt create mode 100644 stats.py create mode 100644 tests/conftest.py create mode 100644 tests/test_backup_stats.py create mode 100644 tests/test_editor.py create mode 100644 tests/test_store.py create mode 100644 tests/ui_smoke.py diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml new file mode 100644 index 0000000..3b9ee26 --- /dev/null +++ b/.github/actions/android-build/action.yml @@ -0,0 +1,34 @@ +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: 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') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt') }}- + purenote-android-v4-${{ runner.os }}- + buildozer-v3- diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 7e4166a..ed7f84f 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -1,64 +1,52 @@ name: Build PureNote APK - on: push: - branches: ["**"] + branches: ['**'] + pull_request: workflow_dispatch: - +permissions: + contents: read +concurrency: + group: apk-${{ github.ref }} + cancel-in-progress: true jobs: + checks: + uses: ./.github/workflows/test.yml build-android: - runs-on: ubuntu-22.04 + needs: checks + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + PYTHONFORANDROID_PREREQUISITES_INSTALL_INTERACTIVE: '0' steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "17" - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - git zip unzip openjdk-17-jdk python3-pip autoconf libtool \ - pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev \ - libtinfo5 cmake libffi-dev libssl-dev build-essential \ - ccache libltdl-dev - - - name: Install buildozer + - uses: actions/checkout@v4 + - 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: | - python -m pip install --upgrade pip - pip install --upgrade buildozer - pip install "cython==0.29.36" - - # Restore the most recent cache for this spec (SDK/NDK + compiled bundle). - # A unique save key per run + prefix restore-keys means progress - # accumulates even across failed builds. - # ~/.android holds the debug.keystore: persisting it keeps the signing - # key stable across builds, so new debug APKs install over the previous - # one without "signatures do not match" / uninstall. - - name: Restore buildozer cache - uses: actions/cache/restore@v4 - with: - path: | - ~/.buildozer - ~/.android - .buildozer - key: buildozer-v3-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} - restore-keys: | - buildozer-v3-${{ hashFiles('buildozer.spec') }}- - + if [ -n "$ANDROID_DEBUG_KEYSTORE_B64" ]; then + mkdir -p "$HOME/.android" + printf '%s' "$ANDROID_DEBUG_KEYSTORE_B64" | base64 --decode > "$HOME/.android/debug.keystore" + chmod 600 "$HOME/.android/debug.keystore" + fi - name: Build debug APK - run: buildozer android debug - - - name: Save buildozer cache + shell: bash + run: buildozer -v android debug 2>&1 | tee build.log + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: purenote-debug-apk + path: bin/*.apk + if-no-files-found: error + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: purenote-apk-build-log + path: build.log + - name: Save build progress if: always() uses: actions/cache/save@v4 with: @@ -66,12 +54,4 @@ jobs: ~/.buildozer ~/.android .buildozer - key: buildozer-v3-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} - - - name: Upload APK artifact - if: success() - uses: actions/upload-artifact@v4 - with: - name: purenote-debug-apk - path: bin/*.apk - if-no-files-found: error + key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 8db277f..0b8126c 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,89 +1,61 @@ name: Build PureNote Release AAB - -# Signed Android App Bundle for the Play Store. -# Triggered manually or by pushing a version tag (e.g. v2.1). -# Requires these repo secrets for a SIGNED bundle (otherwise the AAB is -# unsigned and only good for validating the build): -# ANDROID_KEYSTORE_B64 base64 of the upload keystore (.keystore/.jks) -# ANDROID_KEYSTORE_PASSWORD keystore (store) password -# ANDROID_KEY_ALIAS key alias inside the keystore -# ANDROID_KEY_PASSWORD password for that alias -# See RELEASE.md for how to generate the keystore and set the secrets. - on: workflow_dispatch: push: - tags: ["v*"] - + tags: ['v*'] +permissions: + contents: read jobs: + checks: + uses: ./.github/workflows/test.yml build-release: - runs-on: ubuntu-22.04 + needs: checks + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + PYTHONFORANDROID_PREREQUISITES_INSTALL_INTERACTIVE: '0' steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "17" - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - git zip unzip openjdk-17-jdk python3-pip autoconf libtool \ - pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev \ - libtinfo5 cmake libffi-dev libssl-dev build-essential \ - ccache libltdl-dev - - - name: Install buildozer - run: | - python -m pip install --upgrade pip - pip install --upgrade buildozer - pip install "cython==0.29.36" - - # Reuse the SDK/NDK that the debug workflow already downloaded - # (same buildozer-v3 prefix); the release adds armeabi-v7a on top. - - name: Restore buildozer cache - uses: actions/cache/restore@v4 - with: - path: | - ~/.buildozer - ~/.android - .buildozer - key: buildozer-release-v1-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} - restore-keys: | - buildozer-release-v1-${{ hashFiles('buildozer.spec') }}- - buildozer-v3-${{ hashFiles('buildozer.spec') }}- - + - 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 + id: bundle + shell: bash env: ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | - # Release ships both ABIs (Play serves the right one per device). - sed -i 's/^android.archs = .*/android.archs = arm64-v8a,armeabi-v7a/' buildozer.spec if [ -n "$ANDROID_KEYSTORE_B64" ]; then - echo "$ANDROID_KEYSTORE_B64" | base64 -d > "$HOME/release.keystore" - export P4A_RELEASE_KEYSTORE="$HOME/release.keystore" + printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/release.keystore" + export P4A_RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore" export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" - echo "Keystore present -> building a SIGNED release AAB" + echo 'artifact=purenote-release-aab-signed' >> "$GITHUB_OUTPUT" else - echo "::warning::No ANDROID_KEYSTORE_B64 secret set — building an UNSIGNED AAB (not uploadable to Play)." + echo '::warning::No signing key configured: this AAB cannot be uploaded to Play.' + echo 'artifact=purenote-release-aab-unsigned' >> "$GITHUB_OUTPUT" fi - buildozer android release - - - name: Save buildozer cache + buildozer -v android release 2>&1 | tee build.log + - name: Remove temporary signing key + if: always() + run: rm -f "$RUNNER_TEMP/release.keystore" + - name: Upload AAB + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.bundle.outputs.artifact }} + path: bin/*.aab + if-no-files-found: error + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: purenote-aab-build-log + path: build.log + - name: Save build progress if: always() uses: actions/cache/save@v4 with: @@ -91,12 +63,4 @@ jobs: ~/.buildozer ~/.android .buildozer - key: buildozer-release-v1-${{ hashFiles('buildozer.spec') }}-${{ github.run_id }} - - - name: Upload AAB artifact - if: success() - uses: actions/upload-artifact@v4 - with: - name: purenote-release-aab - path: bin/*.aab - if-no-files-found: error + key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.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..5e1e405 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +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: Upload UI previews + if: always() + uses: actions/upload-artifact@v4 + with: + name: purenote-ui-preview + path: ${{ runner.temp }}/purenote-preview/*.png diff --git a/.gitignore b/.gitignore index e6a4ce2..921f5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.build-venv/ __pycache__/ *.pyc *.bak @@ -9,3 +10,10 @@ PureNote_export/ .buildozer/ bin/ .DS_Store +.pytest_cache/ +.ruff_cache/ +*.db-wal +*.db-shm +*.keystore +*.jks +build.log diff --git a/README.md b/README.md index c9c0088..bcc4270 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,112 @@ # PureNote -A retro/CRT note-taking app for Android, built with Python and Kivy. 100% local and private — no cloud, no account. Your notes live in a local SQLite database. - -# Features -- Multi-note with titles, tags and creation/modification dates -- Live search across titles and bodies -- Tag filtering -- Markdown editor with live preview (headings, bold, italic, code, bullets) -- Interactive checklists: tap `- [ ]` items in the preview to tick them -- Trash with undo: deleted notes are recoverable, with an undo action and a trash bin (restore / empty) -- Pin important notes to the top -- Word / character counter -- Export a note as Markdown and full database backup -- Retro phosphor-green CRT theme - -# Run (desktop) -``` -python3 -m venv .venv -.venv/bin/pip install "kivy[base]" + +Un’app di note Android scritta in Python e Kivy. Rétro sobrio, un solo campo di +scrittura, nessun account. Le note restano in un database SQLite privato. + +

+ Editor PureNote + Lista delle note +

+ +## 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. + +Il backup automatico di sistema Android è disabilitato: l’esportazione manuale +permette di scegliere dove conservare una copia delle note. + +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. + +I contatori riguardano tutto il testo, inclusa la prima riga: + +| 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 | + +## 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 ``` -# Build (Android) -``` -pip install buildozer -buildozer -v android debug # APK -buildozer -v android release # AAB +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. + +## APK su GitHub -# Screenshot -![Screenshot dell'app](images/Screenshot_PureNote.png), ![Screenshot dell'app](images/Screenshot2_PureNote.png) +Il workflow **Build PureNote APK** esegue test della logica, lint e prova UI, poi +compila con Buildozer su Ubuntu. 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 log e anteprime dell’interfaccia. +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). La prima compilazione con questa +configurazione deve essere validata su GitHub; i test locali non dimostrano che +l’APK compili o funzioni su dispositivo. -# Used -Python -Kivy -Buildozer +La cache riusa SDK, NDK, compilazioni parziali e chiave debug. Per una chiave debug +stabile anche dopo la scadenza della cache, puoi impostare il secret opzionale +`ANDROID_DEBUG_KEYSTORE_B64`. L’APK debug è per le prove: per aggiornare +un’installazione già presente occorre una firma compatibile, oppure usare +il backup prima di reinstallare. -# Installation -To install the app on your Android device, download the APK file and follow the installation prompts. -[![Play Store](images/google-play-badge-PureNote.png)](https://play.google.com/store/apps/details?id=bbnss.test.purenote&pcampaignid=web_share) +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. -https://play.google.com/store/apps/details?id=bbnss.test.purenote&pcampaignid=web_share +## Codice e compatibilità dei dati -OR +| 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": []} +``` -## APK Android -https://github.com/bbnss/PureNote/releases/tag/APK +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 index c60c011..5777227 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,56 +1,58 @@ -# Pubblicare PureNote (Release AAB firmato) +# AAB di PureNote -Il workflow `.github/workflows/build-release.yml` produce l'**Android App Bundle -(.aab)** firmato da caricare sul Play Store. Si avvia manualmente -(*Actions → Build PureNote Release AAB → Run workflow*) o pushando un tag -versione (`git tag v2.1 && git push origin v2.1`). +Il workflow **Build PureNote Release AAB** prepara il bundle per il Play Store. +Esegue gli stessi controlli dell’APK e compila per `arm64-v8a` e `armeabi-v7a`. +Non pubblica automaticamente il bundle. -## 1. Genera il keystore (una sola volta) +## Firma -Crea la chiave di firma in locale e **conservala al sicuro** (se la perdi non -puoi più aggiornare l'app sul Play Store): +Per aggiornare l’app già pubblicata, usa il keystore di upload esistente e mantieni +l’application ID `bbnss.test.purenote`. Non sostituire la chiave con una nuova +senza aver completato l’eventuale procedura di cambio chiave nella Play Console. -``` -keytool -genkey -v \ - -keystore purenote-release.keystore \ - -alias purenote \ - -keyalg RSA -keysize 2048 -validity 10000 -``` +Configura questi repository secret in **Settings → Secrets and variables → Actions**: -Ti chiederà una password (store) e i tuoi dati. Annota: -- password del keystore -- alias (`purenote`) -- password dell'alias (può coincidere con quella del keystore) +| Secret | Contenuto | +| --- | --- | +| `ANDROID_KEYSTORE_B64` | File keystore codificato in base64 | +| `ANDROID_KEYSTORE_PASSWORD` | Password del keystore | +| `ANDROID_KEY_ALIAS` | Alias della chiave | +| `ANDROID_KEY_PASSWORD` | Password della chiave | -## 2. Imposta i secret su GitHub +Su Linux puoi produrre la codifica con `base64 -w 0 percorso/chiave.keystore`. +Conserva il file originale e le credenziali fuori dal repository. +Il workflow decodifica la chiave in una cartella temporanea del runner e la rimuove +anche quando la build fallisce. -Converti il keystore in base64: +## Compilazione -``` -base64 -i purenote-release.keystore | pbcopy # macOS, copia negli appunti -``` +Dopo il push autorizzato, apri **Actions → Build PureNote Release AAB → Run workflow**. +In alternativa, il workflow parte con un tag `v*`. L’artifact si chiama +`purenote-release-aab-signed` se il keystore è configurato; senza chiave viene +prodotto `purenote-release-aab-unsigned`, utile per verificare la compilazione +ma non caricabile sul Play Store. -Poi su GitHub: *Settings → Secrets and variables → Actions → New repository -secret* e crea: +La prima build della toolchain aggiornata va ancora convalidata su GitHub. +Prima di pubblicare, prova l’APK su Android: tastiera e barre di sistema, +background/riapertura, aggiornamento con dati esistenti, selettore file, +condivisione, backup e ripristino. Verifica poi i controlli della Play Console. -| Secret | Valore | -|---|---| -| `ANDROID_KEYSTORE_B64` | il base64 del keystore (passo sopra) | -| `ANDROID_KEYSTORE_PASSWORD` | password del keystore | -| `ANDROID_KEY_ALIAS` | `purenote` | -| `ANDROID_KEY_PASSWORD` | password dell'alias | +Ogni nuovo upload richiede un `android.numeric_version` strettamente maggiore +in `buildozer.spec`. Questa revisione usa `version = 3.0` e +`android.numeric_version = 10216`, successivo al precedente 10215. -> Senza questi secret il workflow gira lo stesso ma produce un AAB **non -> firmato**, utile solo a validare la build (non caricabile sul Play Store). +## Compilazione locale facoltativa -## 3. Avvia la build e scarica l'AAB +Con le dipendenze di sistema elencate nell’action Android condivisa: -Lancia il workflow; al termine scarica l'artifact `purenote-release-aab` -(contiene `bin/*.aab`) e caricalo nella Play Console. +```bash +python3.11 -m venv .build-venv +.build-venv/bin/python -m pip install -r requirements-build.txt +.build-venv/bin/buildozer -v android debug +.build-venv/bin/buildozer -v android release +``` -## Note -- Il release builda **entrambe le architetture** (`arm64-v8a` + `armeabi-v7a`); - il Play Store serve poi la ABI giusta a ciascun dispositivo. La build di - debug resta solo `arm64-v8a` per velocità. -- Incrementa `version` in `buildozer.spec` ad ogni release (la Play Console - rifiuta due upload con lo stesso version code). +Il release locale richiede le variabili `P4A_RELEASE_KEYSTORE`, +`P4A_RELEASE_KEYSTORE_PASSWD`, `P4A_RELEASE_KEYALIAS` e `P4A_RELEASE_KEYALIAS_PASSWD` +per firmare il bundle. Le architetture locali sono quelle di `buildozer.spec`; +il workflow GitHub seleziona entrambe le ABI per il release. 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/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 index 65a4105..2af0c19 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -1,84 +1,35 @@ [app] - -# (str) Title of your application title = PureNote - -# (str) Package name package.name = purenote - -# (str) Package domain (needed for android/ios packaging) +# Keep this ID so existing installations retain their private notes. package.domain = bbnss.test - -# (str) Source code where the main.py live source.dir = . - -# (list) Source files to include (let empty to include all the files) -# No `db`: the notes database is created/seeded at runtime, never shipped. source.include_exts = py,png,jpg,ttf,kv - -# (list) Source files to exclude source.exclude_exts = bak,orig -source.exclude_dirs = .venv,images,__pycache__,PureNote_export - -# (str) Application versioning (versionName shown to users) -version = 2.0 - -# (int) Version code: Play Store requires it to strictly increase every upload. -# Previous published versionCode was 10214, so this must stay > 10214. -android.numeric_version = 10215 - -# (list) Application requirements -requirements = python3,kivy==2.3.1,plyer - -# (str) Presplash of the application +source.exclude_dirs = .venv,.build-venv,.git,.github,tests,images,__pycache__,PureNote_export,.pytest_cache,.ruff_cache +version = 3.0 +android.numeric_version = 10216 +requirements = python3,kivy==2.3.1,sqlite3,pyjnius presplash.filename = %(source.dir)s/logo.png - -# (str) Icon of the application icon.filename = %(source.dir)s/logo.png - -# (list) Supported orientations orientation = portrait - -# (bool) Indicate if the application should be fullscreen or not fullscreen = 0 -# -# Android specific -# - -# (bool) If True, then automatically accept SDK license android.accept_sdk_license = True - -# (list) Permissions -android.permissions = WRITE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE - -# (int) Target Android API, should be as high as possible. -# Google Play currently requires target API 35 (Android 15) for new apps/updates. -android.api = 35 - -# (int) Minimum API your APK / AAB will support. +# Notes are private. File access is granted by the system document picker. +android.permissions = +android.api = 36 android.minapi = 24 - -# (str) Android NDK version to use -# android.ndk = 25b - -# (list) The Android archs to build for -# Debug: arm64-v8a only (faster). Re-add armeabi-v7a for the release AAB. +android.ndk = 28c android.archs = arm64-v8a - -# (bool) enables Android auto backup feature (Android API >= 23) -android.allow_backup = True - -# (str) Format used to package the app for release mode (aab for the Play Store). +android.allow_backup = False android.release_artifact = aab - -# (str) Format used to package the app for debug mode (apk for sideloading). 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 [buildozer] - -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) log_level = 2 - -# (int) Display warning if buildozer is run as root (0 = False, 1 = True) warn_on_root = 1 diff --git a/db.py b/db.py index 84dcdb3..63a8326 100644 --- a/db.py +++ b/db.py @@ -1,180 +1,196 @@ -"""SQLite data layer for PureNote. +"""Local notes, transactional migrations, and remembered editor state.""" -Holds the notes store and transparently migrates the legacy single-column -``notes(note text)`` table to the new schema on first run. -""" +from __future__ import annotations -import os import sqlite3 import time +from dataclasses import dataclass +from pathlib import Path -# On Android the cwd is read-only, so the app overrides this with the -# user data dir. On desktop we just use a file next to the source. -DB_PATH = os.environ.get("PURENOTE_DB", "notes.db") +SCHEMA_VERSION = 3 -def _now(): - return int(time.time()) +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=None): - self.path = path or DB_PATH - self.conn = sqlite3.connect(self.path) + 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._init_schema() - - # ------------------------------------------------------------------ schema - def _init_schema(self): - # The legacy app shipped a ``notes`` table with a single ``note`` - # column. Detect it before creating the new table (same name) and - # stash it aside so we can migrate its rows. - self._stash_legacy_table() - self.conn.execute( - """CREATE TABLE IF NOT EXISTS notes ( + 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 - )""" - ) - self.conn.commit() - self._ensure_columns() - self._migrate_legacy() - - def _table_columns(self, table): - return [r[1] for r in self.conn.execute(f"PRAGMA table_info({table})")] - - def _ensure_columns(self): - """Add columns introduced after the first v2 schema (idempotent).""" - cols = self._table_columns("notes") - if "trashed" not in cols: - self.conn.execute( - "ALTER TABLE notes ADD COLUMN trashed INTEGER NOT NULL DEFAULT 0" - ) - self.conn.commit() - - def _stash_legacy_table(self): - cols = self._table_columns("notes") - # New schema already in place -> nothing to stash. - if "id" in cols or "title" in cols: - return - if "note" in cols: - self.conn.execute("ALTER TABLE notes RENAME TO notes_legacy") - self.conn.commit() - - def _migrate_legacy(self): - """Move rows from the stashed ``notes_legacy`` table, if any.""" - if "note" not in self._table_columns("notes_legacy"): - return - rows = self.conn.execute("SELECT note FROM notes_legacy").fetchall() - for (note,) in rows: - body = note or "" - self.add(self._title_from_body(body), body) - self.conn.execute("DROP TABLE notes_legacy") - self.conn.commit() - - @staticmethod - def _title_from_body(body): - first = (body or "").strip().splitlines()[0] if body.strip() else "" - first = first.lstrip("# ").strip() - return (first[:60]) or "Senza titolo" - - # ------------------------------------------------------------------- CRUD - def add(self, title, body, tags="", pinned=0): - ts = _now() - cur = self.conn.execute( - "INSERT INTO notes (title, body, tags, pinned, created_at, updated_at)" - " VALUES (?,?,?,?,?,?)", - (title, body, tags, int(pinned), ts, ts), - ) - self.conn.commit() - return cur.lastrowid - - def update(self, note_id, title, body, tags="", pinned=None): - if pinned is None: + 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( - "UPDATE notes SET title=?, body=?, tags=?, updated_at=? WHERE id=?", - (title, body, tags, _now(), note_id), + "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)" ) - else: self.conn.execute( - "UPDATE notes SET title=?, body=?, tags=?, pinned=?, updated_at=? WHERE id=?", - (title, body, tags, int(pinned), _now(), note_id), + "CREATE INDEX IF NOT EXISTS notes_recent ON notes(trashed, updated_at DESC, id DESC)" ) - self.conn.commit() - - def set_pinned(self, note_id, pinned): - self.conn.execute( - "UPDATE notes SET pinned=?, updated_at=? WHERE id=?", - (int(pinned), _now(), note_id), - ) - self.conn.commit() + self.conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + self.conn.commit() + except Exception: + self.conn.rollback() + raise - def trash(self, note_id): - """Soft-delete: move a note to the trash (recoverable).""" - self.conn.execute( - "UPDATE notes SET trashed=1, updated_at=? WHERE id=?", - (_now(), note_id), + 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), ) - self.conn.commit() - - def restore(self, note_id): + 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( - "UPDATE notes SET trashed=0, updated_at=? WHERE id=?", - (_now(), note_id), + "INSERT OR REPLACE INTO settings(key,value) VALUES('last_note',?)", (str(note_id or 0),) ) - self.conn.commit() - - def delete(self, note_id): - """Permanently remove a note.""" - self.conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) - self.conn.commit() - - def empty_trash(self): - self.conn.execute("DELETE FROM notes WHERE trashed=1") - self.conn.commit() - def count_trashed(self): - return self.conn.execute( - "SELECT COUNT(*) FROM notes WHERE trashed=1" - ).fetchone()[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 is_empty(self): - return self.conn.execute("SELECT COUNT(*) FROM notes").fetchone()[0] == 0 + 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 get(self, note_id): - row = self.conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() - return dict(row) if row else None - - # ----------------------------------------------------------------- queries - def list(self, query="", tag="", trashed=0): - clauses = ["trashed = ?"] - params = [int(trashed)] - if query: - clauses.append("(title LIKE ? OR body LIKE ?)") - like = f"%{query}%" - params += [like, like] - if tag: - clauses.append("(',' || replace(tags,' ','') || ',') LIKE ?") - params.append(f"%,{tag},%") - sql = "SELECT * FROM notes WHERE " + " AND ".join(clauses) - sql += " ORDER BY pinned DESC, updated_at DESC" - return [dict(r) for r in self.conn.execute(sql, params).fetchall()] - - def all_tags(self): - tags = set() - for (raw,) in self.conn.execute( - "SELECT tags FROM notes WHERE tags != '' AND trashed = 0" - ): - for t in raw.split(","): - t = t.strip() - if t: - tags.add(t) - return sorted(tags) - - def close(self): + 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 0000000000000000000000000000000000000000..6ca31e57e0f24454215353a462e159e0b4577b79 GIT binary patch literal 17405 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 B?tuUR literal 0 HcmV?d00001 diff --git a/images/purenote-list.png b/images/purenote-list.png new file mode 100644 index 0000000000000000000000000000000000000000..2b754e8183e39a29a5aed37a17bca9e6baeff3d1 GIT binary patch literal 14522 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 n else "") +BASE_DIR = Path(__file__).resolve().parent -# ------------------------------------------------------------ styled widgets -# Defined in Python so `accent` always has a real (iterable) default before -# the kv canvas rules reference accent[0]/[1]/[2]. -class RetroButton(ButtonBehavior, Label): - accent = ColorProperty(theme.GREEN) +class QuietButton(ButtonBehavior, Label): + primary = BooleanProperty(False) + danger = BooleanProperty(False) -class GhostButton(ButtonBehavior, Label): - accent = ColorProperty(theme.GREEN) - - -class IconButton(ButtonBehavior, Label): - accent = ColorProperty(theme.GREEN) - - -class RetroInput(TextInput): +class NoteInput(TextInput): pass -class ToolButton(ButtonBehavior, BoxLayout): - """Toolbar button rendered as an icon glyph over a small text label.""" - glyph = StringProperty() - label = StringProperty() - accent = ColorProperty(theme.GREEN) - - -# ---------------------------------------------------------------- list screen -class NoteCard(ButtonBehavior, BoxLayout): +class NoteRow(ButtonBehavior, BoxLayout): note_id = NumericProperty(0) title = StringProperty() snippet = StringProperty() - meta = StringProperty() - pinned = BooleanProperty(False) + date = StringProperty() def on_release(self): App.get_running_app().open_editor(self.note_id) - def toggle_pin(self): - app = App.get_running_app() - app.store.set_pinned(self.note_id, not self.pinned) - app.refresh_list() +class ListScreen(Screen): + query = StringProperty() + empty_message = StringProperty() + summary = StringProperty() -class UndoBar(BoxLayout): - """Transient bottom bar with an undo action; `on_undo` set per use.""" - msg = StringProperty("") - def on_undo(self): - pass +class EditorScreen(Screen): + counts = StringProperty("0 parole · 0 caratteri · ≈0 token") + status = StringProperty("Salvataggio automatico") + save_error = BooleanProperty(False) + 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 CheckRow(ButtonBehavior, BoxLayout): - """A tappable checklist row in the preview; toggles the source line.""" - checked = BooleanProperty(False) - label_markup = StringProperty("") - line = NumericProperty(0) - def on_release(self): - App.get_running_app().sm.get_screen("editor").toggle_check(self.line) +class RootManager(ScreenManager): + pass -class TagChip(ButtonBehavior, BoxLayout): - tag = StringProperty() - active = BooleanProperty(False) +class PureNoteApp(App): + insets = ListProperty([0, 0, 0, 0]) - def on_release(self): - app = App.get_running_app() - app.set_tag_filter("" if self.active else self.tag) + def build(self): + 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 -class ListScreen(Screen): - query = StringProperty("") - active_tag = StringProperty("") + 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] -# -------------------------------------------------------------- editor screen -class EditorScreen(Screen): - note_id = NumericProperty(0) # 0 == new note - preview = BooleanProperty(False) - pinned = BooleanProperty(False) - counts = StringProperty("") - _loading = False - - def load(self, note_id): - self._loading = True - self.note_id = note_id or 0 - self._set_preview(False) - app = App.get_running_app() - if note_id: - n = app.store.get(note_id) - self.ids.title.text = n["title"] - self.ids.body.text = n["body"] - self.ids.tags.text = n["tags"] - self.pinned = bool(n["pinned"]) + @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: - self.ids.title.text = "" - self.ids.body.text = "" - self.ids.tags.text = "" - self.pinned = False - self.update_counts() - self._loading = False - - # ------------------------------------------------------------- text/save - def on_text_changed(self): - """Bound to every input's on_text: refresh counts and debounce save.""" - self.update_counts() - if not self._loading: - Clock.unschedule(self._autosave) - Clock.schedule_once(self._autosave, 1.0) - - def _autosave(self, *_): - self._persist() - - def update_counts(self, *_): - body = self.ids.body.text - self.counts = f"{len(body.split())} par / {len(body)} car" - - def _persist(self): - """Write the note to the DB without navigating. Returns True if saved.""" - app = App.get_running_app() - title = self.ids.title.text.strip() - body = self.ids.body.text - tags = ",".join( - t.strip() for t in self.ids.tags.text.split(",") if t.strip() + editor.status = "Salvataggio automatico" + self._stats_trigger() + + def _update_stats(self, *_): + stats = count_text(self.session.text) + self.editor.counts = ( + f"{stats.words} parole · {stats.characters} caratteri" + f" · ≈{stats.estimated_tokens} token" ) - if not title and not body.strip(): - return False - if not title: - title = NoteStore._title_from_body(body) - if self.note_id: - app.store.update(self.note_id, title, body, tags, self.pinned) - else: - self.note_id = app.store.add(title, body, tags, self.pinned) - return True - def manual_save(self): - Clock.unschedule(self._autosave) - if self._persist(): - App.get_running_app().flash("Salvato") - else: - App.get_running_app().flash("Nota vuota") - - # ---------------------------------------------------------------- actions - def _set_preview(self, on): - """Toggle preview mode. - - The preview pane stays in the tree but collapses to size 0 while - editing (a 0-size widget can't intercept touches meant for the body; - a merely-disabled full-size overlay still swallows them on Android). - """ - if on: - self._build_preview() - self.preview = on - - def _build_preview(self): - """Render the body into the preview column as tappable blocks.""" - col = self.ids.preview_col - col.clear_widgets() - for blk in md.to_blocks(self.ids.body.text): - if blk["type"] == "check": - col.add_widget( - CheckRow( - checked=blk["checked"], - label_markup=blk["label"], - line=blk["line"], - ) - ) - else: - col.add_widget(Factory.PreviewLabel(text=blk["text"])) + 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 toggle_check(self, line): - """Flip a `- [ ]`/`- [x]` checkbox on the given body line in place.""" - lines = self.ids.body.text.split("\n") - if not (0 <= line < len(lines)): - return - m = re.match(r"^(\s*[-*]\s+\[)( |x|X)(\].*)$", lines[line]) - if not m: + 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 - lines[line] = m.group(1) + (" " if m.group(2) != " " else "x") + m.group(3) - self.ids.body.text = "\n".join(lines) - self._build_preview() - self._persist() + 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 toggle_preview(self): - self._set_preview(not self.preview) - - def toggle_pin(self): - self.pinned = not self.pinned - if self.note_id: - self._persist() + def new_note(self): + self.open_editor() - def delete(self): - """Soft-delete: move to trash and offer an undo (recoverable).""" - app = App.get_running_app() - Clock.unschedule(self._autosave) - if self.note_id: - nid = self.note_id - app.store.trash(nid) - self.note_id = 0 - app.go_list(save=False) - app.snack_undo("Nota nel cestino", lambda: app.restore_note(nid)) - else: - app.go_list(save=False) + def go_list(self): + if not self.flush(): + return + self.editor.ids.body.focus = False + self.root.current = "list" + self.refresh_list() - def export(self): - App.get_running_app().share_note( - self.ids.title.text, self.ids.body.text + 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] -class RootManager(ScreenManager): - pass + 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() -# ----------------------------------------------------------------------- app -class PureNoteApp(App): - tags = ListProperty([]) - # Safe-area insets (px). Android 15 (target API 35) draws edge-to-edge, - # so we pad content away from the status bar (top) and nav bar (bottom). - inset_top = NumericProperty(0) - inset_bottom = NumericProperty(0) + def on_pause(self): + if hasattr(self, "session"): + self.session.flush() + return True - def build(self): - self.title = "PureNote" - Window.clearcolor = theme.BG - self._read_android_insets() - os.environ.setdefault( - "PURENOTE_DB", os.path.join(_user_dir(), "notes.db") - ) - self.store = NoteStore(os.environ["PURENOTE_DB"]) - self._seed_if_empty() - root = Builder.load_file( - os.path.join(os.path.dirname(__file__), "purenote_ui.kv") + 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="16sp", + color=theme.TEXT, + halign="left", + valign="middle", ) - Window.bind(on_keyboard=self._on_key) - Clock.schedule_once(lambda *_: self.refresh_list(), 0) - return root - - def _seed_if_empty(self): - """On a fresh install (no DB rows) create a single welcome note. - - Replaces shipping a binary seed `notes.db` in the APK: the note is - built in code so it always matches the current feature set and the - repo stays free of a tracked database. - """ - if not self.store.is_empty(): - return - body = ( - "# Benvenuto in PureNote\n\n" - "App di note **100% offline**: i tuoi appunti restano sul telefono.\n\n" - "## Cosa puoi fare\n" - "- Scrivi in **markdown** e tocca *Anteprima*\n" - "- Tocca le caselle qui sotto per spuntarle:\n" - "- [ ] provare la *ricerca* in alto\n" - "- [x] aprire questa nota\n" - "- [ ] fissare una nota con *Fissa*\n\n" - "## Tag\n" - "Aggiungi tag separati da virgola per filtrare le note.\n" + 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) -> Popup: + if self._popup: + self._popup.dismiss() + popup = Popup( + title=title, + content=content, + size_hint=(0.94, height), + background="", + background_color=theme.PANEL, + separator_color=theme.LINE, + title_font=theme.FONT_BODY, + title_color=theme.ACCENT, + title_size="18sp", ) - self.store.add("Benvenuto in PureNote", body, "guida, info", 1) + popup.bind(on_dismiss=lambda *_: setattr(self, "_popup", None)) + 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(8), padding=dp(8)) + 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) + popup = self._dialog(title, box) + for text, action in actions: - def _on_key(self, window, key, *args): - # Android back / Esc: from the editor, save and return to the list - # (consume the event); from the list, let it close the app. - if key == 27: - if self.sm.current == "editor": - self.go_list() - return True - return False + def invoke(fn=action): + popup.dismiss() + fn() - def _read_android_insets(self): - """Read system bar heights (px) so content clears the status/nav bars. + items.add_widget(self._button(text, invoke)) + box.add_widget(self._button("Chiudi", popup.dismiss)) - Android 15 enforces edge-to-edge for apps targeting API 35; without - this the top bar hides under the clock and the Save button/FAB hide - under the gesture/nav bar. No-op on desktop. - """ - try: - from jnius import autoclass + def show_message(self, message: str): + self._actions("PureNote", [], message) - activity = autoclass("org.kivy.android.PythonActivity").mActivity - res = activity.getResources() + def confirm(self, title: str, message: str, callback: Callable, label="Conferma"): + box = BoxLayout(orientation="vertical", spacing=dp(12), padding=dp(8)) + box.add_widget(self.message_label(message)) + popup = self._dialog(title, box, height=0.5) - def dim(name): - rid = res.getIdentifier(name, "dimen", "android") - return res.getDimensionPixelSize(rid) if rid > 0 else 0 + def accept(): + popup.dismiss() + self._storage_action(callback) - self.inset_top = dim("status_bar_height") - self.inset_bottom = dim("navigation_bar_height") - except Exception: - self.inset_top = 0 - self.inset_bottom = 0 + box.add_widget(self._button(label, accept, danger=label == "Elimina")) + box.add_widget(self._button("Annulla", popup.dismiss)) - # ----------------------------------------------------------- navigation - @property - def sm(self): - return self.root + 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( + [("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("PureNote", 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 open_editor(self, note_id): - self.sm.transition = SlideTransition(direction="left") - self.sm.get_screen("editor").load(note_id) - self.sm.current = "editor" + 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 new_note(self): - self.open_editor(0) - - def go_list(self, save=True): - editor = self.sm.get_screen("editor") - Clock.unschedule(editor._autosave) - if save: - editor._persist() - self.sm.transition = SlideTransition(direction="right") - self.sm.current = "list" - self.refresh_list() + 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) - # -------------------------------------------------------------- list data - def set_query(self, text): - self.sm.get_screen("list").query = text - self.refresh_list() + self._storage_action(move) - def set_tag_filter(self, tag): - self.sm.get_screen("list").active_tag = tag - self.refresh_list() + def _expire_undo(self, *_): + self._undo_id = None + self.root.get_screen("list").ids.undo.disabled = True - def refresh_list(self): - ls = self.sm.get_screen("list") - notes = self.store.list(query=ls.query, tag=ls.active_tag) - ls.ids.rv.data = [ - { - "note_id": n["id"], - "title": n["title"] or "Senza titolo", - "snippet": _snippet(n["body"]), - "meta": _fmt_date(n["updated_at"]), - "pinned": bool(n["pinned"]), - } - for n in notes - ] - ls.ids.empty.opacity = 0 if notes else 1 - self._rebuild_tags(ls) - - def _rebuild_tags(self, ls): - bar = ls.ids.tagbar - bar.clear_widgets() - tags = self.store.all_tags() - for t in tags: - chip = TagChip(tag=t, active=(t == ls.active_tag)) - bar.add_widget(chip) - ls.ids.tagscroll.opacity = 1 if tags else 0 - ls.ids.tagscroll.height = "36dp" if tags else 0 - - def restore_note(self, note_id): - self.store.restore(note_id) - self.refresh_list() - self.flash("Ripristinata") - - # --------------------------------------------------------- share / export - def share_note(self, title, body): - """Open the native Android share sheet with the note as plain text.""" - text = (f"{title}\n\n{body}".strip() if title else (body or "")).strip() - if not text: - self.flash("Nota vuota") + def undo_trash(self): + if self._undo_id is None: return - try: - from jnius import autoclass, cast - Intent = autoclass("android.content.Intent") - String = autoclass("java.lang.String") - activity = autoclass("org.kivy.android.PythonActivity").mActivity + def restore(): + note_id = self._undo_id + self.store.set_trashed(note_id, False) + self._expire_undo() + self.open_editor(note_id) - intent = Intent() - intent.setAction(Intent.ACTION_SEND) - intent.setType("text/plain") - intent.putExtra( - Intent.EXTRA_TEXT, cast("java.lang.CharSequence", String(text)) - ) - if title: - intent.putExtra( - Intent.EXTRA_SUBJECT, - cast("java.lang.CharSequence", String(title)), + 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", + ), ) - chooser = Intent.createChooser( - intent, cast("java.lang.CharSequence", String("Condividi nota")) ) - activity.startActivity(chooser) - except Exception: - # Desktop fallback: write a .md file and report where. - self._export_note_file(title, body) - - def _export_note_file(self, title, body): - safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip() - safe = (safe or "nota")[:40] - out_dir = os.path.join(_user_dir(), "PureNote_export") - os.makedirs(out_dir, exist_ok=True) - path = os.path.join(out_dir, f"{safe}.md") - content = f"# {title}\n\n{body}\n" if title else (body or "") - with open(path, "w", encoding="utf-8") as f: - f.write(content) - self.toast(f"Esportato in:\n{path}") - - def export_db(self): - import shutil - - out_dir = os.path.join(_user_dir(), "PureNote_export") - os.makedirs(out_dir, exist_ok=True) - stamp = time.strftime("%Y%m%d_%H%M%S") - path = os.path.join(out_dir, f"purenote_backup_{stamp}.db") - shutil.copyfile(self.store.path, path) - self.toast(f"Backup salvato:\n{path}") - - # ------------------------------------------------------------- ui helpers - def flash(self, message): - """Brief, non-blocking confirmation that fades out.""" - from kivy.uix.label import Label - from kivy.animation import Animation - - lbl = Label( - text=message, - font_name=theme.FONT_TITLE, - font_size=theme.FS_BTN, - color=(theme.GREEN[0], theme.GREEN[1], theme.GREEN[2], 1), - size_hint=(None, None), - size=(Window.width, "40dp"), - ) - lbl.center_x = Window.width / 2 - lbl.y = Window.height * 0.12 + self.inset_bottom - Window.add_widget(lbl) - anim = Animation(opacity=0, duration=1.3, t="in_quad") - anim.bind(on_complete=lambda *a: Window.remove_widget(lbl)) - anim.start(lbl) - - def snack_undo(self, message, on_undo, timeout=4): - """Bottom bar with an ANNULLA action that auto-dismisses.""" - self._undo_dismiss() - bar = Factory.UndoBar(msg=message) - bar.on_undo = lambda: self._undo_do(on_undo) - bar.width = Window.width - dp(24) - bar.x = dp(12) - bar.y = self.inset_bottom + dp(12) - Window.add_widget(bar) - self._undo_bar = bar - Clock.schedule_once(self._undo_dismiss, timeout) - - def _undo_dismiss(self, *_): - Clock.unschedule(self._undo_dismiss) - bar = getattr(self, "_undo_bar", None) - if bar is not None: - Window.remove_widget(bar) - self._undo_bar = None - - def _undo_do(self, cb): - self._undo_dismiss() - cb() - - def toast(self, message): - from kivy.uix.popup import Popup - from kivy.uix.label import Label - - content = Label( - text=message, - font_name=theme.FONT_MONO, - halign="center", - valign="middle", + 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( + note.title, + [ + ("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", + ), + ), + ], ) - content.bind(size=lambda lbl, s: setattr(lbl, "text_size", s)) - Popup(title="PureNote", size_hint=(0.85, 0.4), content=content).open() - def confirm(self, message, on_yes): - from kivy.uix.popup import Popup - from kivy.uix.label import Label - from kivy.uix.button import Button + 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.") - box = BoxLayout(orientation="vertical", spacing=10, padding=10) - box.add_widget(Label(text=message, font_name=theme.FONT_MONO)) - row = BoxLayout(size_hint_y=None, height="48dp", spacing=10) - popup = Popup(title="Conferma", content=box, size_hint=(0.85, 0.45)) + # Snapshot exports and complete validation happen before any import writes. + def export_notes(self): + if not self.flush(): + return - def yes(*_): - popup.dismiss() - on_yes() + 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) - row.add_widget(Button(text="Annulla", on_release=popup.dismiss)) - row.add_widget(Button(text="Elimina", on_release=yes)) - box.add_widget(row) - popup.open() + self._storage_action(export) - def open_backup_menu(self): - from kivy.uix.popup import Popup - from kivy.uix.button import Button + def restore_notes(self): + if not self.flush(): + return + if self.documents: + self.documents.restore(self._preview_restore) + else: + self._desktop_file_dialog() - box = BoxLayout(orientation="vertical", spacing=10, padding=10) - popup = Popup(title="Backup & Export", content=box, size_hint=(0.85, 0.5)) + 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 do(fn): - popup.dismiss() - fn() - - b1 = Button(text="Esporta backup DB", font_name=theme.FONT_TITLE) - b1.bind(on_release=lambda *_: do(self.export_db)) - box.add_widget(b1) - n = self.store.count_trashed() - b_trash = Button( - text=f"Cestino ({n})" if n else "Cestino", - font_name=theme.FONT_TITLE, + 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", ) - b_trash.bind(on_release=lambda *_: do(self.open_trash)) - box.add_widget(b_trash) - b2 = Button(text="Chiudi", font_name=theme.FONT_TITLE) - b2.bind(on_release=popup.dismiss) - box.add_widget(b2) - popup.open() - - def open_trash(self): - from kivy.uix.popup import Popup - from kivy.uix.scrollview import ScrollView - from kivy.uix.button import Button - - trashed = self.store.list(trashed=1) - box = BoxLayout(orientation="vertical", spacing=8, padding=10) - popup = Popup(title="Cestino", content=box, size_hint=(0.94, 0.85)) - if not trashed: - box.add_widget(Label(text="Cestino vuoto", font_name=theme.FONT_MONO)) - else: - scroll = ScrollView() - col = BoxLayout(orientation="vertical", size_hint_y=None, spacing=6) - col.bind(minimum_height=col.setter("height")) - for note in trashed: - row = BoxLayout(size_hint_y=None, height="46dp", spacing=6) - lbl = Label( - text=note["title"] or "Senza titolo", - font_name=theme.FONT_MONO, - halign="left", - valign="middle", - shorten=True, - ) - lbl.bind(size=lambda l, s: setattr(l, "text_size", s)) - row.add_widget(lbl) - rb = Button( - text="Ripristina", font_name=theme.FONT_TITLE, - size_hint_x=None, width="120dp", - ) - xb = Button( - text="Elimina", font_name=theme.FONT_TITLE, - size_hint_x=None, width="100dp", - ) - rb.bind(on_release=lambda *_, i=note["id"]: ( - self.store.restore(i), popup.dismiss(), - self.refresh_list(), self.open_trash(), - )) - xb.bind(on_release=lambda *_, i=note["id"]: ( - self.store.delete(i), popup.dismiss(), self.open_trash(), - )) - row.add_widget(rb) - row.add_widget(xb) - col.add_widget(row) - scroll.add_widget(col) - box.add_widget(scroll) + 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 + ) - bottom = BoxLayout(size_hint_y=None, height="46dp", spacing=8) - if trashed: - eb = Button(text="Svuota cestino", font_name=theme.FONT_TITLE) - eb.bind(on_release=lambda *_: ( - popup.dismiss(), - self.confirm( - "Svuotare il cestino? L'azione non è reversibile.", - lambda: (self.store.empty_trash(), self.flash("Cestino svuotato")), - ), - )) - bottom.add_widget(eb) - cb = Button(text="Chiudi", font_name=theme.FONT_TITLE) - cb.bind(on_release=popup.dismiss) - bottom.add_widget(cb) - box.add_widget(bottom) - popup.open() + 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__": diff --git a/md.py b/md.py deleted file mode 100644 index 690c7fc..0000000 --- a/md.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tiny Markdown -> Kivy markup converter for the preview pane. - -Supports the subset that makes sense on a phone note: headings, bold, -italic, inline code, bullet lists and ``- [ ]`` / ``- [x]`` checklists. -Kivy Labels render a BBCode-like markup when ``markup=True``. -""" - -import re - -from theme import GREEN, MAGENTA, AMBER, TEXT_DIM - - -def _rgba_to_hex(rgba): - r, g, b, _ = rgba - return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" - - -_GREEN = _rgba_to_hex(GREEN) -_MAGENTA = _rgba_to_hex(MAGENTA) -_AMBER = _rgba_to_hex(AMBER) -_DIM = _rgba_to_hex(TEXT_DIM) - - -def _escape(text): - # Kivy markup uses [ ] for tags; escape literals so they render. - return text.replace("&", "&").replace("[", "&bl;").replace("]", "&br;") - - -def _content(text): - """Escape literals then apply inline markdown -> markup.""" - return _inline(_escape(text)) - - -def _inline(text): - """Convert inline markdown (already escaped) to Kivy markup.""" - # `code` - text = re.sub(r"`([^`]+)`", rf"[color=#{_AMBER}]\1[/color]", text) - # **bold** or __bold__ - text = re.sub(r"(\*\*|__)(.+?)\1", r"[b]\2[/b]", text) - # *italic* or _italic_ - text = re.sub(r"(?[/color] {_content(m.group(1))}" - - return _content(raw) - - -def _check_label(checked, text): - """Markup for the text part of a checklist item (dimmed when checked).""" - body = _content(text) - return f"[color=#{_DIM}]{body}[/color]" if checked else body - - -def to_markup(source): - """Render the whole source to a single markup string (static preview).""" - out = [] - for raw in (source or "").split("\n"): - m = CHECK_RE.match(raw.strip()) - if m: - checked = m.group(1).lower() == "x" - box = ( - f"[color=#{_GREEN}]&bl;x&br;[/color]" - if checked - else f"[color=#{_DIM}]&bl; &br;[/color]" - ) - out.append(f" {box} {_check_label(checked, m.group(2))}") - else: - out.append(_line_markup(raw)) - return "\n".join(out) - - -def to_blocks(source): - """Split the source into render blocks for an interactive preview. - - Returns a list of dicts. Consecutive non-checklist lines are merged into - one ``{"type": "markup", "text": ...}`` block; every checklist line becomes - its own ``{"type": "check", "checked": bool, "label": markup, "line": idx}`` - block, where ``line`` is the 0-based index into ``source.split("\\n")`` so a - tap can toggle that exact line back in the body text. - """ - blocks = [] - buf = [] - - def flush(): - if buf: - blocks.append({"type": "markup", "text": "\n".join(buf)}) - buf.clear() - - for idx, raw in enumerate((source or "").split("\n")): - m = CHECK_RE.match(raw.strip()) - if m: - flush() - checked = m.group(1).lower() == "x" - blocks.append( - { - "type": "check", - "checked": checked, - "label": _check_label(checked, m.group(2)), - "line": idx, - } - ) - else: - buf.append(_line_markup(raw)) - flush() - return blocks diff --git a/purenote_ui.kv b/purenote_ui.kv index 3359661..81bd29c 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -1,461 +1,240 @@ #:import t theme #:import dp kivy.metrics.dp +#:import sp kivy.metrics.sp +#:import NoTransition kivy.uix.screenmanager.NoTransition -# ====================================================================== widgets -: - font_name: t.FONT_TITLE - font_size: t.FS_BTN - color: t.TEXT_INV +: + font_name: t.FONT_BODY + font_size: '15sp' + color: t.BG if self.primary else (t.DANGER if self.danger else t.ACCENT) + disabled_color: t.MUTED size_hint_y: None - height: dp(46) - canvas.before: - Color: - rgba: self.accent if self.state == 'normal' else (self.accent[0], self.accent[1], self.accent[2], 0.7) - RoundedRectangle: - pos: self.pos - size: self.size - radius: [t.RADIUS] - -: - font_name: t.FONT_TITLE - font_size: t.FS_BTN - color: self.accent - size_hint_y: None - height: dp(46) + height: dp(48) + text_size: self.width - dp(12), self.height + halign: 'center' + valign: 'middle' + shorten: True canvas.before: Color: - rgba: (self.accent[0], self.accent[1], self.accent[2], 0.12 if self.state == 'down' else 0.0) + rgba: t.ACCENT if self.primary else (t.LINE if self.state == 'down' else t.PANEL) RoundedRectangle: pos: self.pos size: self.size - radius: [t.RADIUS] - Color: - rgba: self.accent - Line: - rounded_rectangle: (self.x, self.y, self.width, self.height, t.RADIUS) - width: 1.1 + radius: [dp(10)] -: - font_name: t.FONT_MONO - font_size: '20sp' - color: self.accent if self.state == 'normal' else t.TEXT - size_hint: None, None - size: dp(44), dp(44) - -: - background_color: t.BG_INPUT - foreground_color: t.TEXT - cursor_color: t.GREEN - selection_color: (t.GREEN[0], t.GREEN[1], t.GREEN[2], 0.3) - hint_text_color: t.TEXT_DIM - font_name: t.FONT_MONO - padding: [dp(10), dp(10)] +: background_normal: '' background_active: '' + background_color: t.BG + foreground_color: t.TEXT + hint_text_color: t.MUTED + cursor_color: t.ACCENT + selection_color: (0.3, 0.6, 0.4, 0.35) + font_name: t.FONT_BODY + font_size: '18sp' + padding: [dp(20), dp(16)] + write_tab: False -: - orientation: 'vertical' - spacing: dp(1) - padding: [dp(2), dp(6)] - canvas.before: - Color: - rgba: (self.accent[0], self.accent[1], self.accent[2], 0.14 if self.state == 'down' else 0.0) - RoundedRectangle: - pos: self.pos - size: self.size - radius: [t.RADIUS] - Label: - text: root.glyph - font_name: t.FONT_MONO - font_size: '19sp' - color: root.accent - size_hint_y: None - height: dp(24) - Label: - text: root.label - font_name: t.FONT_MONO - font_size: '10sp' - color: root.accent - size_hint_y: None - height: dp(14) - halign: 'center' - valign: 'middle' - text_size: self.width, None - shorten: True - -: - markup: True +: font_name: t.FONT_MONO - font_size: t.FS_BODY - color: t.TEXT + font_size: '11sp' + color: t.MUTED halign: 'left' - valign: 'top' - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - padding: [dp(6), dp(2)] + valign: 'middle' + text_size: self.size -: - orientation: 'horizontal' +: size_hint_y: None - height: max(dp(30), lbl.texture_size[1] + dp(10)) - padding: [dp(6), dp(2)] - spacing: dp(6) - canvas.before: - Color: - rgba: (t.GREEN[0], t.GREEN[1], t.GREEN[2], 0.12 if self.state == 'down' else 0.0) - Rectangle: - pos: self.pos - size: self.size - Label: - text: '[x]' if root.checked else '[ ]' - font_name: t.FONT_MONO - font_size: t.FS_BODY - color: t.GREEN if root.checked else t.TEXT_DIM - size_hint_x: None - width: dp(36) - halign: 'left' - valign: 'top' - text_size: self.width, None - Label: - id: lbl - text: root.label_markup - markup: True - font_name: t.FONT_MONO - font_size: t.FS_BODY - color: t.TEXT - halign: 'left' - valign: 'top' - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - -: - size_hint: None, None - height: dp(48) - padding: [dp(12), dp(4)] + height: dp(72) + padding: [dp(16), dp(12)] spacing: dp(8) - canvas.before: - Color: - rgba: t.BG_PANEL - RoundedRectangle: - pos: self.pos - size: self.size - radius: [t.RADIUS] - Color: - rgba: t.GREEN - Line: - rounded_rectangle: (self.x, self.y, self.width, self.height, t.RADIUS) - width: 1.1 - Label: - text: root.msg - font_name: t.FONT_MONO - font_size: t.FS_META - color: t.TEXT - halign: 'left' - valign: 'middle' - text_size: self.size - GhostButton: - text: 'ANNULLA' - accent: t.AMBER - size_hint_x: None - width: dp(100) - on_release: root.on_undo() -# ======================================================================== root -RootManager: - ListScreen: - name: 'list' - EditorScreen: - name: 'editor' +: + text: 'PURENOTE' + font_name: t.FONT_RETRO + font_size: '24sp' + color: t.ACCENT + halign: 'left' + valign: 'middle' + text_size: self.size -# ==================================================================== top bars -: +: + orientation: 'vertical' size_hint_y: None - height: dp(58) - padding: [dp(12), dp(6)] - spacing: dp(8) + height: dp(116) + padding: [dp(22), dp(12)] + spacing: dp(4) canvas.before: Color: - rgba: t.BG_PANEL + rgba: t.PANEL if self.state == 'down' else t.BG Rectangle: pos: self.pos size: self.size Color: - rgba: t.GREEN + rgba: t.LINE Rectangle: - pos: self.x, self.y - size: self.width, dp(1.5) - -# ==================================================================== note card -: - orientation: 'horizontal' - size_hint_y: None - height: dp(86) - padding: [dp(12), dp(8)] - spacing: dp(8) - canvas.before: - Color: - rgba: t.BG_PANEL - RoundedRectangle: - pos: self.x + dp(6), self.y + dp(4) - size: self.width - dp(12), self.height - dp(8) - radius: [t.RADIUS] - Color: - rgba: (t.AMBER if self.pinned else t.GREEN) - RoundedRectangle: - pos: self.x + dp(6), self.y + dp(4) - size: dp(4), self.height - dp(8) - radius: [t.RADIUS] - BoxLayout: - orientation: 'vertical' - spacing: dp(2) - Label: - text: root.title - font_name: t.FONT_TITLE - font_size: t.FS_BODY - color: t.TEXT - halign: 'left' - valign: 'middle' - shorten: True - shorten_from: 'right' - text_size: self.width, None - size_hint_y: None - height: dp(24) - Label: - text: root.snippet - font_name: t.FONT_MONO - font_size: t.FS_META - color: t.TEXT_DIM - halign: 'left' - valign: 'top' - shorten: True - text_size: self.width, self.height - Label: - text: root.meta - font_name: t.FONT_MONO - font_size: t.FS_META - color: t.GREEN_DIM - halign: 'left' - valign: 'middle' - text_size: self.width, None - size_hint_y: None - height: dp(16) - IconButton: - accent: t.AMBER if root.pinned else t.TEXT_DIM - text: '*' - on_release: root.toggle_pin() - -# ===================================================================== tag chip -: - orientation: 'horizontal' - size_hint_x: None - width: self.minimum_width - padding: [dp(10), dp(4)] - canvas.before: - Color: - rgba: t.GREEN if root.active else (t.GREEN[0], t.GREEN[1], t.GREEN[2], 0.12) - RoundedRectangle: - pos: self.pos - size: self.size - radius: [dp(12)] + pos: self.x + dp(22), self.y + size: self.width - dp(44), dp(1) Label: - id: lbl - text: '#' + root.tag - font_name: t.FONT_MONO - font_size: t.FS_META - color: t.TEXT_INV if root.active else t.GREEN - size_hint_x: None - width: self.texture_size[0] + text: root.title + font_name: t.FONT_BODY + font_size: '18sp' + color: t.TEXT + halign: 'left' + valign: 'middle' + text_size: self.width, self.height + shorten: True + shorten_from: 'right' + Label: + text: root.snippet + font_name: t.FONT_BODY + font_size: '14sp' + color: t.MUTED + halign: 'left' + valign: 'middle' + text_size: self.width, self.height + shorten: True + shorten_from: 'right' + QuietLabel: + text: root.date + size_hint_y: None + height: dp(20) -# =================================================================== list screen : BoxLayout: orientation: 'vertical' - padding: [0, app.inset_top, 0, app.inset_bottom] + padding: app.insets TopBar: - Image: - source: 'logo.png' + Wordmark: + QuietButton: + text: '+' + primary: True + font_size: '26sp' size_hint_x: None - width: dp(40) - allow_stretch: True - keep_ratio: True - Label: - text: 'PURENOTE' - font_name: t.FONT_TITLE - font_size: '24sp' - color: t.GREEN - halign: 'left' - valign: 'middle' - text_size: self.size - IconButton: - accent: t.MAGENTA - text: '=' - on_release: app.open_backup_menu() - # search + width: dp(48) + on_release: app.new_note() + QuietButton: + text: 'Menu' + size_hint_x: None + width: dp(64) + on_release: app.open_menu() BoxLayout: size_hint_y: None - height: dp(48) - padding: [dp(12), dp(6)] - RetroInput: + height: dp(60) + padding: [dp(16), dp(4)] + NoteInput: id: search - hint_text: 'cerca...' + hint_text: 'Cerca nelle note' multiline: False + font_size: '16sp' + padding: [dp(14), dp(14)] + background_color: t.PANEL on_text: app.set_query(self.text) - # tag filter bar - ScrollView: - id: tagscroll + BoxLayout: size_hint_y: None - height: dp(36) - do_scroll_y: False - BoxLayout: - id: tagbar - orientation: 'horizontal' + height: dp(44) + padding: [dp(22), 0] + QuietLabel: + text: root.summary + QuietButton: + id: undo + text: 'Annulla eliminazione' + font_size: '13sp' + disabled: True + opacity: 0 if self.disabled else 1 size_hint_x: None - width: self.minimum_width - spacing: dp(6) - padding: [dp(12), 0] - # list + width: dp(160) + height: dp(44) + on_release: app.undo_trash() FloatLayout: RecycleView: - id: rv - viewclass: 'NoteCard' + id: notes + viewclass: 'NoteRow' + bar_width: dp(3) RecycleBoxLayout: - default_size: None, dp(86) + default_size: None, dp(116) default_size_hint: 1, None size_hint_y: None height: self.minimum_height orientation: 'vertical' Label: id: empty - opacity: 0 - text: 'nessuna nota\\n\\npremi + NUOVA per iniziare' - font_name: t.FONT_MONO - color: t.TEXT_DIM + text: root.empty_message + font_name: t.FONT_BODY + font_size: '17sp' + color: t.MUTED halign: 'center' - pos_hint: {'center_x': .5, 'center_y': .5} - RetroButton: - text: '+ NUOVA' - font_name: t.FONT_MONO - accent: t.GREEN - size_hint: None, None - size: dp(130), dp(50) - pos_hint: {'right': 0.97, 'y': 0.03} - on_release: app.new_note() + valign: 'middle' + text_size: self.width - dp(48), self.height -# ================================================================= editor screen : BoxLayout: orientation: 'vertical' - padding: [0, app.inset_top, 0, app.inset_bottom] - # --- title bar: back + screen name + live counter + padding: app.insets TopBar: - IconButton: - accent: t.GREEN - text: '<' + QuietButton: + text: 'Note' + size_hint_x: None + width: dp(60) on_release: app.go_list() - Label: - text: 'NOTA' if root.note_id else 'NUOVA' - font_name: t.FONT_TITLE - font_size: '20sp' - color: t.GREEN - halign: 'left' - valign: 'middle' - text_size: self.size - Label: - text: root.counts - font_name: t.FONT_MONO - font_size: t.FS_META - color: t.TEXT_DIM - halign: 'right' - valign: 'middle' - text_size: self.size - # --- action bar: clear icon + label buttons - BoxLayout: + Wordmark: + font_size: min(sp(21), self.width / 5.4) + halign: 'center' + QuietButton: + text: '+' + font_size: '26sp' + size_hint_x: None + width: dp(48) + on_release: app.new_note() + QuietButton: + text: 'Menu' + size_hint_x: None + width: dp(60) + on_release: app.open_menu() + Widget: size_hint_y: None - height: dp(52) - padding: [dp(6), dp(2)] - spacing: dp(4) - canvas.before: + height: dp(1) + canvas: Color: - rgba: t.BG_PANEL + rgba: t.LINE Rectangle: - pos: self.pos - size: self.size - ToolButton: - glyph: 'v' - label: 'Salva' - accent: t.GREEN - on_release: root.manual_save() - ToolButton: - glyph: '*' - label: 'Fissa' - accent: t.AMBER if root.pinned else t.TEXT_DIM - on_release: root.toggle_pin() - ToolButton: - glyph: 'o' - label: 'Modifica' if root.preview else 'Anteprima' - accent: t.MAGENTA if root.preview else t.TEXT_DIM - on_release: root.toggle_preview() - ToolButton: - glyph: '^' - label: 'Condividi' - accent: t.GREEN - on_release: root.export() - ToolButton: - glyph: 'x' - label: 'Elimina' - accent: t.DANGER - on_release: root.delete() - # --- inputs + pos: self.x + dp(20), self.y + size: self.width - dp(40), dp(1) + NoteInput: + id: body + hint_text: 'Comincia da un pensiero…' + line_spacing: dp(6) + on_text: root.on_text_changed(self.text) BoxLayout: orientation: 'vertical' - padding: [dp(12), dp(8)] - spacing: dp(8) - RetroInput: - id: title - hint_text: 'titolo' - font_name: t.FONT_TITLE - font_size: '20sp' - multiline: False - size_hint_y: None - height: dp(48) - on_text: root.on_text_changed() - RetroInput: - id: tags - hint_text: 'tag separati da virgola' - font_size: t.FS_META - multiline: False - size_hint_y: None - height: dp(38) - on_text: root.on_text_changed() - # body / preview live in one cell; the inactive one is disabled - # (disabled widgets don't receive touches) so there is no overlap. - FloatLayout: - id: body_area - RetroInput: - id: body - hint_text: 'scrivi... (markdown supportato)' - font_size: t.FS_BODY - size_hint: 1, 1 - pos_hint: {'x': 0, 'y': 0} - opacity: 0 if root.preview else 1 - disabled: root.preview - on_text: root.on_text_changed() - ScrollView: - id: preview_box - # full-size only in preview; collapses to 0x0 while editing - # so it cannot intercept touches meant for the body input - size_hint: (1, 1) if root.preview else (None, None) - size: 0, 0 - pos_hint: {'x': 0, 'y': 0} - opacity: 1 if root.preview else 0 - disabled: not root.preview - do_scroll_x: False - BoxLayout: - id: preview_col - orientation: 'vertical' - size_hint_y: None - height: self.minimum_height - padding: [dp(6), dp(6)] - spacing: dp(2) + size_hint_y: None + height: dp(88) if root.save_error else dp(72) + padding: [dp(20), dp(4), dp(20), dp(12)] + Label: + text: root.counts + font_name: t.FONT_BODY + font_size: '12sp' + color: t.MUTED + halign: 'left' + valign: 'middle' + text_size: self.size + QuietButton: + text: root.status + font_size: '12sp' + danger: root.save_error + height: dp(48) if root.save_error else dp(32) + halign: 'left' + on_release: app.flush() if root.save_error else None + +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/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_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..0144d8c --- /dev/null +++ b/tests/ui_smoke.py @@ -0,0 +1,165 @@ +"""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 main import PureNoteApp # noqa: E402 + +OUTPUT = Path(os.environ.get("PURENOTE_SCREENSHOTS", "/tmp/purenote-preview")) +OUTPUT.mkdir(parents=True, exist_ok=True) +Window.size = (390, 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.navigation, + self.list_capture, + self.trash_and_restore, + self.menu_capture, + self.keyboard_layout, + 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.root.export_to_png(str(OUTPUT / "editor.png")) + + def failure_and_retry(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() + self.store.save_note = 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): + 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, 24, 0, 290] + + def keyboard_layout(self): + assert self.editor.ids.body.height > 200 + assert self.editor.ids.body.y >= 290 + self.root.export_to_png(str(OUTPUT / "keyboard-layout.png")) + self.insets = [0, 0, 0, 0] + Window.size = (320, 568) + + def narrow_screen(self): + assert self.editor.ids.body.width == 320 + assert self.editor.ids.body.height > 300 + self.root.export_to_png(str(OUTPUT / "small-screen.png")) + self.explain_counts() + + def counts_dialog(self): + assert self._popup is not None + + 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/theme.py b/theme.py index dabddce..e14ec2e 100644 --- a/theme.py +++ b/theme.py @@ -1,36 +1,16 @@ -"""Central retro/CRT theme for PureNote. +"""A restrained terminal palette, with ordinary, readable writing typography.""" -Colors are RGBA tuples in 0..1. The palette evokes an old phosphor terminal: -near-black background, phosphor green as the primary accent and a magenta -secondary, echoing the original app's green/magenta buttons. -""" +from pathlib import Path -# ------------------------------------------------------------------ palette -BG = (0.043, 0.055, 0.047, 1) # #0b0e0c deep CRT black-green -BG_PANEL = (0.078, 0.094, 0.082, 1) # #141815 raised panels / cards -BG_INPUT = (0.027, 0.035, 0.030, 1) # #070907 text fields -LINE = (0.16, 0.22, 0.17, 1) # subtle separators / borders +from kivy.resources import resource_find -GREEN = (0.25, 0.95, 0.45, 1) # #40f273 primary phosphor accent -GREEN_DIM = (0.18, 0.55, 0.30, 1) # dimmed green for secondary text -MAGENTA = (0.95, 0.25, 0.75, 1) # #f240bf secondary accent -AMBER = (1.0, 0.78, 0.30, 1) # #ffc74d warnings / pin highlight - -TEXT = (0.82, 0.95, 0.84, 1) # main readable text (soft green-white) -TEXT_DIM = (0.45, 0.58, 0.48, 1) # metadata, placeholders -TEXT_INV = (0.043, 0.055, 0.047, 1) # text on bright (green) backgrounds -DANGER = (0.95, 0.35, 0.35, 1) # delete - -# ------------------------------------------------------------------- fonts -FONT_MONO = "GS.ttf" # Green Screen — body / terminal feel -FONT_TITLE = "TT.ttf" # 806 Typography — headers / buttons - -# -------------------------------------------------------------------- sizes -FS_TITLE = "22sp" -FS_BODY = "17sp" -FS_META = "12sp" -FS_BTN = "16sp" - -RADIUS = 6 -PAD = "12dp" -GAP = "8dp" +BG = (0.047, 0.071, 0.059, 1) +PANEL = (0.071, 0.102, 0.082, 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") From e33b847b3d0a7975d05800d78ce2610d6c9c6f31 Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 26 Oct 2026 19:56:21 +0100 Subject: [PATCH 13/29] Pin pure Python charset-normalizer for Android packaging --- buildozer.spec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/buildozer.spec b/buildozer.spec index 2af0c19..79c205d 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -9,7 +9,8 @@ source.exclude_exts = bak,orig source.exclude_dirs = .venv,.build-venv,.git,.github,tests,images,__pycache__,PureNote_export,.pytest_cache,.ruff_cache version = 3.0 android.numeric_version = 10216 -requirements = python3,kivy==2.3.1,sqlite3,pyjnius +# 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 orientation = portrait From b94e82f248638482a9c8675bb4ac1baaa637224d Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 26 Oct 2026 20:01:54 +0100 Subject: [PATCH 14/29] Recreate cached p4a installer environments before Android builds --- .github/actions/android-build/action.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml index 3b9ee26..837afea 100644 --- a/.github/actions/android-build/action.yml +++ b/.github/actions/android-build/action.yml @@ -32,3 +32,15 @@ runs: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.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 From 16b40f16545f3ac66499a20f20a790cb30f5b545 Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 26 Oct 2026 20:08:36 +0100 Subject: [PATCH 15/29] Constrain p4a cross-platform dependency resolution to portable wheels --- .github/actions/android-build/action.yml | 7 +++++-- .github/workflows/build-apk.yml | 2 +- .github/workflows/build-release.yml | 2 +- README.md | 3 +++ RELEASE.md | 1 + buildozer.spec | 2 +- constraints-android.txt | 3 +++ 7 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 constraints-android.txt diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml index 837afea..1f2c539 100644 --- a/.github/actions/android-build/action.yml +++ b/.github/actions/android-build/action.yml @@ -20,6 +20,9 @@ runs: - 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: @@ -27,9 +30,9 @@ runs: ~/.buildozer ~/.android .buildozer - key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} + 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') }}- + 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 diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index ed7f84f..3a4c30b 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -54,4 +54,4 @@ jobs: ~/.buildozer ~/.android .buildozer - key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} + 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/build-release.yml b/.github/workflows/build-release.yml index 0b8126c..47cdcdf 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -63,4 +63,4 @@ jobs: ~/.buildozer ~/.android .buildozer - key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} + key: purenote-android-v4-${{ runner.os }}-${{ hashFiles('buildozer.spec', 'requirements-build.txt', 'constraints-android.txt') }}-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/README.md b/README.md index bcc4270..f19d4e8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ SDK 36, NDK 28c e Android minimo 7.0 (API 24). La prima compilazione con questa configurazione deve essere validata su GitHub; i test locali non dimostrano che l’APK compili o funzioni 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, compilazioni parziali e chiave debug. Per una chiave debug stabile anche dopo la scadenza della cache, puoi impostare il secret opzionale `ANDROID_DEBUG_KEYSTORE_B64`. L’APK debug è per le prove: per aggiornare diff --git a/RELEASE.md b/RELEASE.md index 5777227..d0d64fb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -48,6 +48,7 @@ Con le dipendenze di sistema elencate nell’action Android condivisa: ```bash python3.11 -m venv .build-venv .build-venv/bin/python -m pip install -r requirements-build.txt +export PIP_CONSTRAINT="$PWD/constraints-android.txt" .build-venv/bin/buildozer -v android debug .build-venv/bin/buildozer -v android release ``` diff --git a/buildozer.spec b/buildozer.spec index 79c205d..456ff9a 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -10,7 +10,7 @@ source.exclude_dirs = .venv,.build-venv,.git,.github,tests,images,__pycache__,Pu version = 3.0 android.numeric_version = 10216 # 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 +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 orientation = portrait 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 From b4d37e43aab95c8b9422bb262153b8f4ecae5b18 Mon Sep 17 00:00:00 2001 From: bbnss Date: Mon, 26 Oct 2026 20:17:09 +0100 Subject: [PATCH 16/29] Omit empty Android permission and verify packaged APKs in CI --- .github/workflows/build-apk.yml | 2 ++ README.md | 9 ++++---- buildozer.spec | 2 +- tests/verify_apk.py | 39 +++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 tests/verify_apk.py diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 3a4c30b..90868b6 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -34,6 +34,8 @@ jobs: - name: Build debug APK shell: bash run: buildozer -v android debug 2>&1 | tee build.log + - name: Verify APK signature and permissions + run: python tests/verify_apk.py bin/*.apk - name: Upload APK uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index f19d4e8..6d0f66c 100644 --- a/README.md +++ b/README.md @@ -63,15 +63,16 @@ inset verifica il layout ridotto: non sostituisce una prova della tastiera Andro ## APK su GitHub Il workflow **Build PureNote APK** esegue test della logica, lint e prova UI, poi -compila con Buildozer su Ubuntu. Parte su push, pull request oppure manualmente +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 log e anteprime dell’interfaccia. 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). La prima compilazione con questa -configurazione deve essere validata su GitHub; i test locali non dimostrano che -l’APK compili o funzioni su dispositivo. +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. diff --git a/buildozer.spec b/buildozer.spec index 456ff9a..dcefd80 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -18,7 +18,7 @@ fullscreen = 0 android.accept_sdk_license = True # Notes are private. File access is granted by the system document picker. -android.permissions = +# Omit android.permissions entirely: an empty value becomes android.permission. android.api = 36 android.minapi = 24 android.ndk = 28c diff --git a/tests/verify_apk.py b/tests/verify_apk.py new file mode 100644 index 0000000..405e269 --- /dev/null +++ b/tests/verify_apk.py @@ -0,0 +1,39 @@ +"""Check the actual Android artifact before publishing it in CI.""" + +import argparse +import re +import subprocess +from pathlib import Path + + +def verify_apk(apk: Path, sdk: Path) -> None: + candidates = list((sdk / "build-tools").glob("*/apksigner")) + if not candidates: + raise FileNotFoundError(f"Android build tools not found in {sdk}") + signer = max(candidates, key=lambda path: tuple(map(int, re.findall(r"\d+", path.parent.name)))) + subprocess.run([str(signer), "verify", "--verbose", str(apk)], check=True) + 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) + ) + 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( + "--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) From 245389b40768337d7ad04dfdc78612e9d7d856f4 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 09:09:40 +0100 Subject: [PATCH 17/29] Rework mobile layout and add emulator APK builds with density checks --- .github/workflows/build-apk.yml | 11 ++ .github/workflows/test.yml | 10 +- README.md | 4 + RELEASE.md | 4 +- buildozer.spec | 4 +- images/purenote-editor.png | Bin 17405 -> 16677 bytes images/purenote-list.png | Bin 14522 -> 16165 bytes main.py | 74 +++++++-- purenote_ui.kv | 273 ++++++++++++++++++++------------ tests/ui_smoke.py | 37 ++++- theme.py | 1 + 11 files changed, 291 insertions(+), 127 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 90868b6..817bdb7 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -4,6 +4,14 @@ on: 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: @@ -20,6 +28,9 @@ jobs: 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e1e405..266dcac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,9 +26,17 @@ jobs: LIBGL_ALWAYS_SOFTWARE: '1' PURENOTE_SCREENSHOTS: ${{ runner.temp }}/purenote-preview run: xvfb-run -a python tests/ui_smoke.py + - name: Check Android density and enlarged text + env: + KIVY_NO_CONSOLELOG: '1' + LIBGL_ALWAYS_SOFTWARE: '1' + KIVY_METRICS_DENSITY: '2.625' + KIVY_METRICS_FONTSCALE: '1.5' + 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/*.png + path: ${{ runner.temp }}/purenote-preview/ diff --git a/README.md b/README.md index 6d0f66c..428ad72 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ 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 150%, con verifiche +su testo, barra superiore e contatori per evitare sovrapposizioni. ## APK su GitHub @@ -67,6 +69,8 @@ compila con Buildozer su Ubuntu e verifica firma e assenza di permessi nell’AP 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 log e anteprime dell’interfaccia. +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), diff --git a/RELEASE.md b/RELEASE.md index d0d64fb..1758810 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -38,8 +38,8 @@ background/riapertura, aggiornamento con dati esistenti, selettore file, condivisione, backup e ripristino. Verifica poi i controlli della Play Console. Ogni nuovo upload richiede un `android.numeric_version` strettamente maggiore -in `buildozer.spec`. Questa revisione usa `version = 3.0` e -`android.numeric_version = 10216`, successivo al precedente 10215. +in `buildozer.spec`. Questa revisione usa `version = 3.0.1` e +`android.numeric_version = 10217`, successivo al precedente 10216. ## Compilazione locale facoltativa diff --git a/buildozer.spec b/buildozer.spec index dcefd80..def31e1 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -7,8 +7,8 @@ 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,__pycache__,PureNote_export,.pytest_cache,.ruff_cache -version = 3.0 -android.numeric_version = 10216 +version = 3.0.1 +android.numeric_version = 10217 # 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 diff --git a/images/purenote-editor.png b/images/purenote-editor.png index 6ca31e57e0f24454215353a462e159e0b4577b79..b36ce4319bda931264a17cfc58217d5b28a6d13c 100644 GIT binary patch literal 16677 zcmeHvc{H1A*S8)@tA_S;Fco#Gtxh-;MM+hMV^-0oN(^mPO;MUqV_HsiL~BY7L2I5u z%@U-l<)CUvj1i$~NFs?MLL$j`>vNuGJ>UDDXRYV`)_TA7t@XYC5qIuu-`C#P-oO3( z?b}m(+e^}tijpECBGMMWop%rs*%B)vA}X~_9QXusN+d``VbziXAz zIrB{HOt=9?m1U)yVP`v=X>Fq0Mo*X%J$x@8YJmv}I&^R8u@J&KB_VQlQg)`MQslvh zgYqIG;;kYg+JGGo%(O*Bq@uToxbKt_5s7gZ5s?=Y6?uQ)@0UFLS*pjVu55R{W538T zt3&m^TX)1_OKPxX)*>kkNAF?DpQZAzhW;-4zy)o|xIPi{#_@QV2wPChask_E`&(sgL*GjU^yfc2cy}O`5IfPG= zC7x!?bJo)X9F$fAENVUnE^lj>rV}}zN7m1*jmBrv*Qd0yKa;q>G@q*nOxbd*Ciz%p zV)3r9wMB5j<6Pn1|8^E)ehxviolvcPu$^T;^YLKFnkY<<}s5yNVmW}XowDj3Xg zLcblC7hDs<@h40kXHDb7zZRl;mi*Qu3WY5m^n$?xxsyuu3xUA5fVFPacs|O!grU3b zYqkI5_mhv%mC40wI}D8KP4GhzNNO4WHH+=kc5i#E%WMXmJGFW1x83_;AAQq!dx#LtCT(3pW*^B^TKmk3by2QKk6x2%dAMeo!NNx?@5Qi@q0JPBLPYg{n&=PQzA%--vO zg#)ejGLeBPn8cVj1)i1$lsk>q82xmF-xYcd!3LD%X@_n3t>Pu)`mf)xfP($WqnP>lra$To$0Ibq+aHkkk@L?9*&p5y3RbwkK=R@*Zk5$s?iCCN z^p7N^OVY6B+tLJE(EPG&_bGV+S8&yUpSA84I$Fr``6JtuNt#ZEwbuqSfqDAVtp($|n|=TOC11NSJF= z%%)>K_e*h@q_2f?SDm#t7`Yw%rt?CrLigW(Y=FNE)WUP8%nv6x?1){~RPu|OOjVb! zYV6pla%BG5QIz|niaAqqbasz9hTc0}0z`Fc6>#O*yUR{?(p20$OFHos(E??`v0Vvy zRd+Ee4r<%_;Paj;=Zr z3DPF6bk8*ej&W!X4B2m=_!ck5qSjrU+n`16lc?U#KSL%P^cM1yvB%NUZ|)i?_>^Qg zw~-jKx#@oo%-(FWCU8T?!4-(@oB$CRH(j#JXSlRl^$4>f(&s)Fudir8$!oQbM?$YC z)_Y&2R~3>kZXgDmj>DDOfrrEF3I5=)mtiNnL-_M)^jOsMmz})rRqLM+4hX(!tUBUQ z8Z38gGlWZ;)#SU*A6@ue#L`NQv>%(;b@p$+7}owpX84y$ikzu~2QQZtTE5skT9G_(qD z;8KU(H6B}*m7;f?T{jvWt8J1nKMJ`4WjKm-QHqI}z3TFo?|!D=xb%~~lWj_HxqGX< zWQx-QTKs)+ZX&7nEs%+GBK|rr|LfGfW&{a=6aA36WObjh2a{37N9 zm>-3F6ULl_v#Cgaxkl^ahY1w<4GaH-&m4a^w|?l>fKNC zHlhT}WsmMz-B#GLA=_%d1z2|7F|jpgsiKKrq7>BmNI*F#JTp7NTM!K)H>C}>C6eI|YTSwXU&b_9Pqgh>bh-Lne7jrKG| z#fSR~m0HI8cC|UoTy*=joxehH8&A?QnLccd{&rjQ_4M5G6eJsaxICi}nq_)ru7MS0 zN^8445jTueSoY!xk;?=Q#;O9=V%<@V<+d}c54vG57*OU5%iDz=!i|NCZia{T$}NVW z43uy8YB{sS8$7-=c%0miR0#=C9-p%fZ~+z0SR1+ex4!Jz=%pet+3hQ#)t?u=Ttg?6 zH@@b+plLLn5!KwkQ1H2-9ogbLZDP8iRPU1tHret=MWS@-yxea}**O$2pix`KQZ>3$01_5lFx@XMqm)SqI;>n#=l(~qfM*)oYtqbH zC(ad=J2y-Vji%bPl8h|JQj!@#Km9Q2&duX){I&A%Z^a$HnZr(VE2{+X^nOlcZmJFW zJ2Lw&$F#C|EM)p{0*BbZU!*6rym^+IX5^cmEx3S^;bey_gbheo4(2TDmL!d(>Lz^$ z66niY0)ujw*_a$B(pACoUhv_yK)Bv33+0)h1T|9FPcNIRB*ocw0mxu4+OjG=o5|yZ zGz+Rg=2r~P_XR}q2i9Wqc%#i5oE@l>wzwv!Qid%U(hwYwQu1vMV3l6T6gZB52R47 zoh1Zu#|6Xh)uqJ|-eP1cET^y9Ex=%->Xn|px>-z}J5|~n1tDBcyHJ0kc$J*g5aSCJA}X}|uEccBQ#1X3EGoJA$o3Ct-Uqj4cS+d6 z4wdIN%sdCM25R?N!Jb3%E3b=Xn2lEZUQZS3mCa=bT^7ylP>GoW)}D?MCfsB`mCM%m zToD8NOFx}0=Iu43G|qR*LkGnoXVVj42OgtJU6W#&aiXlXBY|J;jK5E)c-cRlK1EGX z3~6+W4NPkHYR*i10Rd}&bFdpI?nrAH7nn5^>kzz_dim04purdU0wx; za;1xw;$CvQ5kp3ZRRoO13s}$iBP{uf=Sp5&)bt`XB%7y{jeXm{TURoAakq}IK?P@- zRO4CAdY;{U2g94Eo40uQO<)kV-D&WqF>GQL8hKzOI80sd%o%Ux3#dzwc&uc7Daj(}^PwHZ;am`@uJJDC+aOep6oX`8^xSzCcOYb28(UJQ4jyaECu(Cf%Hc95&fu za;}llc*MsgtA$@W{zWcpsrx(Q=|O=6thw}yz9(^!`pTMJUOJ8$b}d<*K8ZL|ybFA= zu-Z#65o@6o^}8?FT?>^pd~o z8~#f*PW~UzcMFkzSJ)CwIew* zvTp01vc|0FhYB_zdqT}Arjo=&;PB@(rYV0_x1LvSQv6+0Mel=2{oGa#U+WTi?vf1d zQuf3wmd(6e?wU9>cPLl5;7St4R^VjuT;fUW`bEwk-~?P`b`$DYwHL*eGt?S6*v{v6 zur@YgdTY0uxriovH+y2q#t7EeLgd<-0TY*k?6C7H6t5jOwCL+{I_xUAsBb%T>Z!&=a9N@wE= z#Z5Jazn5VIBHV>PP%Tp#jqdRQ8_12`Uf4>?O^2Do(2!cykbf$(eA}Xn-&fk% za7T49-#wXQ_Y(r{Zx+z|Tn}o&URNzf*;rrXk7^BZ; zxmcr!1>a$eRfiAZ`A`UU{WcEDbwp2h3|5sq%=#5sClnfhB3alKT55y!K$x*G+Kf3; z0cuRZ8-9Z|un$vSGI=YJc$qXYb4~VZ_;}I0&Wb9RguUZtQ{LSf>B#drEuZ*Bi!@W> z2sB({^R(;RV`~}Xtti~Rwsgh0T+srX!qsArlVkO(N}QNpkhL_4`|)NJcS^TGyeFd} z#B^*<7Q?ks%rB5L;n}z4-jzS?S1H^-%uj-IFH^CJHT!)k+t_N7C(w`3Lq)DWBc9V8 zT<;fYr!}X!j)rJ|`sbrf<3?-Gc1ev6IS()}y3-X=8$-3s5ly{?&#qqUSm?XkSIXJS z1{9aNZm<@JPPgt>l=8h7gey{mhEQTZdL|B$bSusXNvn*&+a)f9p_AiBj@ZfT0PQaA zS8BuMe$T$a*YR!fWTOm3f2ZBrYa1iHAF9+L#ZUVqd$&(pv2$+0`p39>AF>ZfR(Yq9 zJN)kaWL#1%yy)qH4_>9Zd%?D!Sbd&dwBWkt=^zMvbU7;g)G;lbf7(TtK5 zWrNTrR36|o-@ckx*;mKE=U&wJ2I^Hp+6$$dyj{e6J}LMe3)U&NL;L$QIkQ}x%3XeqRzrnZRgrP43$6?m+iMLT zpY`Mu{ZLFV&Qi9VrgLU6*P=_k+~85JKs97F!{YCTy2hrj4lbTa`l5vbV(7y5*h!J6 zJX^2nk_{!u2`ilW)+*^(>hkUNP)BKn$*@%(1S4x25uebxPr{_1+FcxA)@m+lo?S`qyD(^%tqn#oMd` zn5zaSb7z7!L-Ay1@UV>=0`#%mZGd{{C<>0dgCAHD<81#xd_r#zO4PBS+u-GgBA+xc zQP!KT_vDrhFS$MUAxUvq$SAE#EM0fjX zViQ>iBT_q6pydlDi@`px3*IO}rb35&2l_zQ3k!|mJN0}?$AY~S^q4_-O{1hfFN6WL zI!nIu7B{e4)u0^CeuD+$=XYzF`p43r=K4jOt^83~pb#Z_kU6_7N>W*}%lJLJvcJmL zcs6L+kQ8QxCD&FI2ND%-+}}7}VEtQ-@KcWg5aTobktz(|{uvpjg8I>NHi5cRhVy_g$+4=}CaDnjtx-DEMR!!aop2+KJc zCaXSRk(WFfcqOI#M|kEQXlnQ^6zcQ1q4bxBOiu zdSPL)t`ZgcS;2mKCuo+IVebzyEC*V1(dQ)|W~~ne?0oBq(5C(hLVNo-1vjrc)T)XV z8(T=!Df*zjsqYD+K`W0fciY*z84M%t(k6jzI2}l^gs9Rk+Jov;`oYt0=4KJ^?=-Wg zGyP~_yGjiy2hd&P(U9^&oe!-CBrQyq%Yy=tYZ&}!$=Ob}1Z6B5d77TyM)tvdJbuukFL1S&rK*=e`rUflRwV74h$5g zTjNCyo_&0!IHj`ZAI$6qFU+OEqb$0-GcMw`AHRHoj~KL8Q9U2jA1>v`>3Ul#4t7x}9;eZ0tF@)wK8@98+BsLn%su;39%C)uwh^TZL2L(2`eg{5ol}}RY1xvvOR8vZ ztGxm|+gdsU>%4*_&w)eUVo$P@!G`5l&^~?*_|Vzy;>2g}Mf(qGs!~};=hYrhe!pR2 z>1<1X@L^gS_HSX`>Es=;4fug;&sI>su!p?}%`rJ6Y{%j5dnIpLE~B=^{+w^{iP{_S zm~sGu`lrvu?d>+Hc;ar6T;P!7ql!7(-4Kjh9A*cPuY_7DDDNZ`UOiqahoaAX3wy%_ zpcxM@kdM5XUQh^Gfa-c-k-3QiZec8)To#6Y>#AFfS1AtaufO(5?>-tE@_m}xD@$iU z!n#u@JX}a&p1Q6B*eA|kOy}6_0M@|9%3LB_IKPpY+*y$2v*TGFJq1QO1l3GIQhUe( zG$|~yaH6cRK7oqJ3ZqmBl4~Yr^S`?RKu{@a^pCPSXVclFTag2Ums}!`80tTW(73KW zee#~^G;}mD=}U*3NbN%ynPK7JAdU#*c|Qi)_WVin(Swti_hm`sMMUHhxNBU zIS4u55F#|s$*}D5_QB&)?DqVANW4XylF8&)2xFcX#7?)2%Y|I^iF%w6$rwXWUk9VB zl(oE}=TTr5XOLS`=4xPcQ{X2=HW$_bM9S0dTh4YC(Q2JxpMWq+wURDuhs@tmW}CY5 z-Ig!s7K3Cl9@oX2vq|_3(-q9aJkrVGb#-VqoV}}@mDeHuQue|QaXnjqqgp#@-~N_b zkUVH@qm5X3l2p$XbzohWiKU|ZjLv@0(i>ih7t((>2GD-7M;N4ZqFBi^#;338DblT4 z<_mz0);;<~ZM9a1j9r~W{7{O)w=1eQRv=|gDgUs4d6Q8=g4{e~Jp7G97NxteJbiV| zB!81b+hf(~d+QlGPMQqWFjioxj6Zk=%cfFMHp>B{Z)|+l9wA#sWV1~JuD1V${jm*l z#bwv)$5?KkMA%ph>uffCEPGlxGt3DWd!d6c zN@j+*K8F_gb#&)xe&{Yf0*)8*e#dbh+6=ZU{SCF$vG;HcN$h9GQJq2g;odK?5V*dw zN1-6=DMb^_ zZZ7iug}f3i`mG?PyQC*q*k>j%Zceq!Zj*2s&LV>e3Av`<8rYq|P1dwunli)MQU&=`eU5%wvi`&Q$C-U!xVOt4_9YYjP}2uX;#jU@-A*&U`Qosv&fQ95f% z^wypqa5fGee7Qxi46tYXIb(yeK=Vsd_e^-~RRgxq4)GN54|YXA!A#C=5)T&5@0}9W zl(+K=#&*i<1ue{v{KE`0^IWAYYC%Sx^*`^%%z(RB6?y5`Z1sPvntUFTsQC$TF|QkL z;=N0AcjortJyO!nr@oUlXrTBn2OHqSyOp{7O()?5TLV!9K`UuXfPv#PwXp?4GmacQ(pJ&qDLPGY2XN9Z_ZtGB-Fe7EJBd4IH-gyV9s5O;K} z;fgNOqKWgKB&b%|;wWToR`qstAV2eZj2XO=*16N<7kR1D;k-Awm?+DM!oLBF&F})IwX`sRAzTF)-7W z-}>biZpJ7(kDjD1uk&XDD6(^uNY4_LGf~2z!-x&t;2HgZW{NC90j;tt;!ejw!Qre} za?qTlZu5L=ULk131*>v_*mD|MLZ2K`xUp6ocSm#>NAnXVKUE}t>PB%dZ83n2E_BZa zeuUGL8TT#dzhE=@;*RU>9+W~B&S}RSwz#_D-exle3e4Lqv7L2UiDKr7Y|G)DhoKl3 zrG>wr?>wl5MS^sXRl?Wcg(1V~={?)_)E>8xPRZIjtdNlvi1!)W1GYs?(#!{CBZ=>+ z)PxeVIo=X?mjgVke|_Gacr)7Q{-8WFjFEpHbQdgaY6stZAhEcpp|hCyUeurD71>?5O+ykxTr0nfe^}&$|1Ffs zzg6}Avv_R#)crN?eoMGuP>!q+rEvXe4*^dsTbAG6h>C_%(p-}Ku+)bzE#Lz(k`}m&dH$i(&%41&Zv?>dS zw}&h)9)W#Izru`l4hY(8)hU6&3t?50Q}&jUi1&97%wCEqh#TLezX7`M^3`tW@V3~l z%gOHZ#OCMtb0hpOz8IkGo81gOF4_xV@ln?KdlXwASf1B*j0f6^FHJQudAL(ZKonrM zRha6v#l|&#lm&5nrIKgPR%3KLt z&>I>_uJa0RXzRcSx(OCjgvf-J$e=9z5Cfo38+>Q2L$afIX&`6W7J|hin%VLOb1oM~ zx@Y$~w7q{Ni(K6e{S%0gW$}XfRaK+O!*gk>xY{Wcin2Kk$@Bu)##h<<Bozr z5w-!s^^G0kjhi}zsbKacD`>!`CNjH)6~D3k+S@%Twe+a`7eLk!!njRNx7UECpCuFZ z{gn{8sh6N_I2ZSkBXT#19Ll?>KZ%?m*R1)uyyfT5Y`s?Y-i&Z-#4c>AF@^U4Ixk|I zfj9Zg71#g*!uww5N1B8;L*$knu$Bu&2 zY{t%+9&wDFo~Fga2Qh#eYlVVRc(fbAg&D2jgl@31Dq ziCkz|Z=z?Sr}zUm5rXLN1(VCNgds>>>&rYrq9%C2Xa{&_)5h#^fmDNYc2+2*0{HMR z9~oXyv;3%VAgV%Fg>iOl6h6&b!`n4n$_i_`ZV2rXm-ZV73N*e$v^kDikDDm-KQ44K z=(^$x**(@s9HCD>i8nwDopNKowR${Pb7pGuiR)D?xNyXk0v#jyXb$%m8LOx!XIZQ^TMw1j`KNS8K`fpHe_HG!RTS_|BR=Jb zo);!q&HnHfTq2IZUvL%ho(@|tWvh;?zGVG0AdC?`JGD!>ViWz~yq9l(`-k2ARKYs| zwI$=mFkOpReHbAj#_ywbZR;v#ImV^N(xt2t41HYtxlRp!fpH{5(t3wpdM!Km5=-w< zzVGi9Xk&7M@#0&xcikEO+Qh5Kv=f_Nm{)mi>r)59Rkt%<{Y&$ImNJ^GG0*3jo}TW% z2zGlTK+@62OH+0jR9bX@VpImMs@x}KGfJ~BOPf_Du@^Wb+d|Xv`O`6)E$@+qF~+Ew z?MPcAd;{W%Ed=VpjhKto+vH=T#@e)$6i9y*Ww?JE>4){S*S4s-c)~6Ch2ma$GEG~p z0*ci1I>J;;{^PQ^$_E`=^P7I3?7rRj{`HUeLD!^M|h zRUR8^FbNvn3@0Gn5PqZ^IN4|HYU4skN}fjR-OU2)Dckt%!w3s;HVPnMk+@DIlpO65R$oyYjx=;`5dGi@R=K8pMKb;oLl{to0bf zqp)6ep6dX@s;WF_ly%JFazkL1B zhLF{8F}R$brMNuHxDIi?3RNg54MC{UqOPr3D4`D&^;^hESkKDY_Wt458xc+=g{Mws zFM<_DHSJaazg@UcSRG2|3#w(rOlAv9707vn%EgMXVVEoHvHw`rD8;LPIusjC*s}N3 ztxaSpevY0?xo?e*M3Z;|XTilRl_Q@$1=Q#_A%mj=3l%-d=O`_9f+el@%AE~EgU`St zrDt(l*)^kx<=bHaQHvN=ySTGhuB75Nexw?@@`N*@v_f<++y;Q3G%c#mp<-PVbS^J= zOeLK%vs-XOEWvTs7jKDG_9gw9Me^vWx12q3z?Kvtk5ij=h!+U1CSndG&yMwfc(b$5 z6P@)_pUgZ`qwdxl5_`<5n6mGdgQmB(7|3S__Tq<{Y3i%fXtwub;+X;d`;&y3r_L>T z%R8RiUP6t~Ba$xk{iiC9}53*kR%gNsQ zBk^nQ!)D3!4Z_x#q68;)PK=}1GS|j>tx|B1ntRoww=Neq?USDa|AO+8KrJS+%ZW9fH>9@xto5osM?VDoSZD z%)BJmk3oF8<2H(p-nn)-Cm0yTF*5Eu6W1G-&3;xP^bdgp_jX>(Z;11bfae(H&daPh9F|Ed3iVpB58+spQJo{%;LI zZi_wj7OCKXJm-vzHzebBe!ZnDBSO@RR*So%J%7@UySsU+9yEA3BTys@D63q)v|?)OFwIhbX~q2?Xd}Q#2vJlL#0xv3RkMqO1%uz zs(Gjym~m+VxmH}s1V9t=)7uGj#M;PkamdKCXtRnj@J$S4g{oY}#TN*H!J7?Zgq}4k zauDaDm%WJFxkpOT&Eu_3SX!g&yQ1%H70l9;@+%mpN2vC}pwF*CB%vGYnY1fF?~7<$ z0z`oZ{Dq2(PI0U6J!1HCJe9~kyjv}zBe+<{Yt;ylzlOKh-{>hJYHy91SRX4fClM=Q zkzri%YjHtq$G&|4xh*`l%Qomf@w}^+N;K88)`CDpA{W;m7kkuhZQB%uDD^pkf1^%3 z-Z5LG-%X30H3Sgm!U#JrK%4#3_XZZ-!26q<4kq>#7mdvh#xGx|RY#jaw)O{6gh51n z-kCGjYPqqYGvtuG*!*yht`bCP9 znSbfXMI_L$N9GnE|0yzRHf4_d&JDQ)k(~;HSf@=IaTZd!nF;Rpqq7V_2%#ac^!ASq ztyyBiR=SiiTMd}fT^rR;o$opV+b^Oaae-=i9T(ahaKONSbm6e8ZYStj$t4T?Y3`Mh zhfFA<4tf2zIL6l*EAsEfN#pHFGkRGnAnAgX1ot&69>3;2UoZQXVqTQ_;g|-*S$jpv zekR~G3s5u-e5YIt{xNs45SFY5+i&tTza!8%nC`M7EiGcP_Yg1@k>|xnOgkkM(KwQu zb2kV&xh)w?;SU{AS)R(FDE^?U{U3fS1qgt~C}B?N9=_l#zG7QK#y1HVkO3RfT4!e3 z&L;HHBw&&a@agf0jv(R9(_}xbi_M~E^=%tLPhT-rL`5bI{lv#H z%Bb@Tb`!3Z^Ooiz%vVQLL(t(_X@LsKsagCmTz7jy`8(_9tL5Z53XKp zFJYjZ+%yrn)T0&x;8$jUVO^uG$=3uhL#dY)+N#WD-V2#}^1SP|@{XtOYvgRj%qiEX zd-dVW6ez}LQDivaYzoC`HL{5Uj0Bo})zOfSbJn{D_OiKG(bU|5USnx7r<4wB#?8OM&_%j9JzG(76w zhil>YBG~DtPGlfXs7FtQ%t`4W(a zkSfMqk%8YTwURLXb`IkCZve0^c)dU9dmpiKb0((^&IaTgMR<#~Kg1;q ztUzoA(6pwQ$@!d8)5v&VV6w+75Nfh~ozuZBI6J{RTMjpr(954|=LshJD_0a;*Zi zUKkI|Am~~;6u8iG63+-$f;nVh=u%dAhUpqQrx!96;QY=u5flq6EOW<>JwUng5 z2E=it3WJ#;Sj#Y|-?5AC2F#j13C|c05L9kGkYb_mNG6 z%)5Ahtd}`D!!k||QwFq+MPrlJoNgQb;Wy4K6~U|e2Qb$|Cu0V{gr2!db@F-mp%l}t z?YzgPi(Y~`nKQlYc@oKuTuO&PcxHx(3rA0^D2b;yRj!Bw(~(iDc)MeT?2}@^aR_U1 zB8vyEws39(htEW;t@x5bmg?BMK_Mc#A|5#%d`6f@zvoKvnSocF>%hRy;UgEJz(_m@ z)nl~p=YR86#=pu~OJ%NvSkKy~vwK=!O88@g?^+p@ohbFZ=uyKQm>}Qnt*W{{8#L7H zre%N}jPtDhJoCW)57Fp1I^%VoalQiu7sh&vc>X_M(B9L2R8DH_75rlTUA7IZXIg3e z+g8H3w=_Kz^0}$2JZw#+p8JZ;Ogye-lj2qt)rboupM-s``3~#Lm*b)l?1S|228G=~ zUdp*?gT-DsV6;<-r@Mz?Z*$#%GgEu84pdrA zCsvoL*0*&E={P#8q2IuMsy&C1rc9O&(OM?JCR$P`+i@#Y*Q*Feni0o{P#!UF(3DBu^Wovo(-Osh<(vgbb?mk z;u10Qo5AYX_u^i*o&HjoVML<;Ys`ySAo~I)!pDFq?yaN){3S_@zmP18pgwafl$6}EO?CW?bKfa*!@jm72 zJY5h%_PkQcbAir>xMtWnpK;TeFNk{q&WrG@p)_)QP>jBgn4<0)&8%g-l5B8Rb#<&?5lN_VSBzce&mh$@PQ zjE5BZ4i=*;oc$le_+f{~VyRv8#^_L=F<=<;Vr?(A?s%*HwO0MUU*$p;9?_BQJ@?4j zM_g0KE+zU^G`P=?<1%oJR=r}3Is%l1?955r+=fFjX8dQgdycu141?Ks`K{;OcHm_>Y9Vm2sbjkX*eoH-Top~xpYW~o~r1LrJ^eA_qP__3BXaWpuq zts^10RHdb(JTxLEqv>wCartGF&4P$~mDJv_ms7qv-P`Q{%69HDt z7f(>UI=$9-;<&xFn5gBs0r!{b82RhLK6K_pT|Zl4q<7GFtPgtnc7yZ!dnofoJG*D& zlvWA>@3Sa}as3J$oj-)gc~4YBfxq)wAST^lFfQZQ{)F8!u|VKpGlsM{!U{^$I~@I8 zI3KSUm(8g9>C;7EAAq-xH5)c=7%UI1-yZ8WpiE)2?xiXOb35BPu{cztvmgwo1PZUB zN5OiDA#Lq4A>G2@$Iil!l!%=!_ol5uXU0*Sql zHq&0Xva|1Y0ddh^n0V1fWm?Gw9a-ujEfK?AL{wk(;s$1i%g)nNVb`cb%yD4U-z$xk z{w$;L%NlDOJ4D%&72179b*IndkuS|-9DZr(9hx+O&zDF^2tQ^4HA7{&3{Bp%nVzdE wJNqjzO!5~m|Nlj(|HuCi@L#y(g-}+K+zU3^`*ri*ABb37usx4C=N|pP04jb=t^fc4 literal 17405 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 B?tuUR diff --git a/images/purenote-list.png b/images/purenote-list.png index 2b754e8183e39a29a5aed37a17bca9e6baeff3d1..35d7f1b18ecac906162a5e15b0d60337cb029d14 100644 GIT binary patch literal 16165 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 B`^o?S literal 14522 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 Label: label.bind(size=lambda widget, size: setattr(widget, "text_size", size)) return label - def _dialog(self, title: str, content, *, height=0.65) -> Popup: + def _dialog(self, title: str, content, *, height=0.65, preferred_height=None) -> Popup: if self._popup: self._popup.dismiss() popup = Popup( title=title, content=content, - size_hint=(0.94, height), + size_hint=(None, None), background="", background_color=theme.PANEL, separator_color=theme.LINE, title_font=theme.FONT_BODY, title_color=theme.ACCENT, title_size="18sp", + title_align="left", + padding=dp(16), ) - popup.bind(on_dismiss=lambda *_: setattr(self, "_popup", None)) + + 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.center = ( + left + (Window.width - left - right) / 2, + bottom + (Window.height - top - bottom) / 2, + ) + + 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) + fit() self._popup = popup popup.open() return popup @@ -296,7 +331,7 @@ def _button(self, text: str, callback: Callable, *, danger=False, primary=False) return button def _actions(self, title: str, actions: list[tuple[str, Callable]], message=""): - box = BoxLayout(orientation="vertical", spacing=dp(8), padding=dp(8)) + box = BoxLayout(orientation="vertical", spacing=dp(12)) if message: scroll = ScrollView(do_scroll_x=False) label = self.message_label(message, scrollable=True) @@ -308,23 +343,31 @@ def _actions(self, title: str, actions: list[tuple[str, Callable]], message=""): items.bind(minimum_height=items.setter("height")) scroll.add_widget(items) box.add_widget(scroll) - popup = self._dialog(title, box) + button_height = max(dp(48), sp(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() - items.add_widget(self._button(text, invoke)) + 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), padding=dp(8)) - box.add_widget(self.message_label(message)) - popup = self._dialog(title, box, height=0.5) + 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() @@ -345,7 +388,11 @@ def open_menu(self): actions = [] if self.root.current == "editor": actions.extend( - [("Condividi nota", self.share_note), ("Sposta nel cestino", self.trash_note)] + [ + ("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)) @@ -359,7 +406,7 @@ def open_menu(self): ("Informazioni sui contatori", self.explain_counts), ] ) - self._actions("PureNote", actions) + self._actions("Opzioni nota" if self.root.current == "editor" else "Opzioni", actions) def explain_counts(self): self.show_message( @@ -430,7 +477,7 @@ def _trash_actions(self, note_id: int): if note is None: return self._actions( - note.title, + "Nota nel cestino", [ ("Ripristina", lambda: self._storage_action(lambda: self._restore_note(note_id))), ( @@ -443,6 +490,7 @@ def _trash_actions(self, note_id: int): ), ), ], + message=note.title, ) def _restore_note(self, note_id): diff --git a/purenote_ui.kv b/purenote_ui.kv index 81bd29c..8b0e144 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -9,18 +9,39 @@ color: t.BG if self.primary else (t.DANGER if self.danger else t.ACCENT) disabled_color: t.MUTED size_hint_y: None - height: dp(48) - text_size: self.width - dp(12), self.height + height: max(dp(48), sp(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.PANEL) + 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(10)] + 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: '' @@ -29,41 +50,85 @@ 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: '18sp' - padding: [dp(20), dp(16)] + padding: [dp(24), dp(20)] write_tab: False : - font_name: t.FONT_MONO - font_size: '11sp' + font_name: t.FONT_BODY + font_size: '12sp' color: t.MUTED halign: 'left' valign: 'middle' - text_size: self.size + text_size: self.width, None + shorten: True + shorten_from: 'right' : size_hint_y: None - height: dp(72) + height: max(dp(48), sp(15) + dp(28)) + dp(24) padding: [dp(16), dp(12)] - spacing: dp(8) + spacing: dp(12) : text: 'PURENOTE' font_name: t.FONT_RETRO - font_size: '24sp' + font_size: min(dp(23), self.width / 5.8) color: t.ACCENT halign: 'left' valign: 'middle' - text_size: self.size + 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: dp(116) - padding: [dp(22), dp(12)] + height: max(dp(46), sp(22) + sp(16) + dp(4)) spacing: dp(4) + Label: + text: root.value + size_hint_y: None + height: sp(22) + font_name: t.FONT_MONO + font_size: '15sp' + 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: sp(16) + font_name: t.FONT_BODY + font_size: '11sp' + 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), sp(24) + sp(22) + sp(16) + dp(44)) + padding: [dp(24), dp(16)] + spacing: dp(6) canvas.before: Color: rgba: t.PANEL if self.state == 'down' else t.BG @@ -73,8 +138,8 @@ Color: rgba: t.LINE Rectangle: - pos: self.x + dp(22), self.y - size: self.width - dp(44), dp(1) + 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 @@ -82,23 +147,20 @@ color: t.TEXT halign: 'left' valign: 'middle' - text_size: self.width, self.height + text_size: self.width, None shorten: True shorten_from: 'right' - Label: - text: root.snippet - font_name: t.FONT_BODY + size_hint_y: None + height: sp(24) + QuietLabel: + text: root.snippet if root.snippet else 'Nessun altro testo' font_size: '14sp' - color: t.MUTED - halign: 'left' - valign: 'middle' - text_size: self.width, self.height - shorten: True - shorten_from: 'right' QuietLabel: text: root.date + font_name: t.FONT_MONO + font_size: '11sp' size_hint_y: None - height: dp(20) + height: sp(16) : BoxLayout: @@ -106,53 +168,48 @@ padding: app.insets TopBar: Wordmark: - QuietButton: - text: '+' - primary: True - font_size: '26sp' - size_hint_x: None - width: dp(48) - on_release: app.new_note() - QuietButton: - text: 'Menu' - size_hint_x: None - width: dp(64) + IconButton: + icon: 'menu' on_release: app.open_menu() BoxLayout: + orientation: 'vertical' size_hint_y: None - height: dp(60) - padding: [dp(16), dp(4)] + 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), sp(25) + dp(8)) + Label: + text: 'Le tue note' + font_name: t.FONT_BODY + font_size: '25sp' + color: t.TEXT + text_size: self.width, None + halign: 'left' + shorten: True + QuietLabel: + text: root.summary + 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: '16sp' - padding: [dp(14), dp(14)] + 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) - BoxLayout: - size_hint_y: None - height: dp(44) - padding: [dp(22), 0] - QuietLabel: - text: root.summary - QuietButton: - id: undo - text: 'Annulla eliminazione' - font_size: '13sp' - disabled: True - opacity: 0 if self.disabled else 1 - size_hint_x: None - width: dp(160) - height: dp(44) - on_release: app.undo_trash() FloatLayout: RecycleView: id: notes viewclass: 'NoteRow' bar_width: dp(3) RecycleBoxLayout: - default_size: None, dp(116) + default_size: None, max(dp(112), sp(24) + sp(22) + sp(16) + dp(44)) default_size_hint: 1, None size_hint_y: None height: self.minimum_height @@ -165,66 +222,78 @@ color: t.MUTED halign: 'center' valign: 'middle' - text_size: self.width - dp(48), self.height + 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), sp(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: - QuietButton: - text: 'Note' - size_hint_x: None - width: dp(60) + id: toolbar + IconButton: + icon: 'back' on_release: app.go_list() Wordmark: - font_size: min(sp(21), self.width / 5.4) halign: 'center' - QuietButton: - text: '+' - font_size: '26sp' - size_hint_x: None - width: dp(48) - on_release: app.new_note() - QuietButton: - text: 'Menu' - size_hint_x: None - width: dp(60) + IconButton: + icon: 'menu' on_release: app.open_menu() - Widget: - size_hint_y: None - height: dp(1) - canvas: - Color: - rgba: t.LINE - Rectangle: - pos: self.x + dp(20), self.y - size: self.width - dp(40), dp(1) + Divider: NoteInput: id: body - hint_text: 'Comincia da un pensiero…' - line_spacing: dp(6) + hint_text: 'Scrivi qui…' + line_spacing: dp(5) on_text: root.on_text_changed(self.text) + Divider: BoxLayout: orientation: 'vertical' size_hint_y: None - height: dp(88) if root.save_error else dp(72) - padding: [dp(20), dp(4), dp(20), dp(12)] - Label: - text: root.counts - font_name: t.FONT_BODY - font_size: '12sp' - color: t.MUTED - halign: 'left' - valign: 'middle' - text_size: self.size + 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), sp(22) + sp(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), sp(12) + dp(8)) + halign: 'center' QuietButton: - text: root.status - font_size: '12sp' - danger: root.save_error - height: dp(48) if root.save_error else dp(32) - halign: 'left' - on_release: app.flush() if root.save_error else None + text: 'Salvataggio fallito · Riprova' + font_size: '13sp' + danger: True + disabled: not root.save_error + opacity: 1 if root.save_error else 0 + height: max(dp(48), sp(13) + dp(28)) if root.save_error else 0 + on_release: app.flush() RootManager: transition: NoTransition() diff --git a/tests/ui_smoke.py b/tests/ui_smoke.py index 0144d8c..009ff95 100644 --- a/tests/ui_smoke.py +++ b/tests/ui_smoke.py @@ -12,12 +12,14 @@ 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 = (390, 780) +Window.size = (dp(390), dp(780)) class SmokeApp(PureNoteApp): @@ -75,6 +77,7 @@ def autosaved(self): 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): @@ -127,23 +130,43 @@ 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, 24, 0, 290] + self.insets = [0, dp(24), 0, dp(290)] def keyboard_layout(self): - assert self.editor.ids.body.height > 200 - assert self.editor.ids.body.y >= 290 + 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.insets = [0, 0, 0, 0] - Window.size = (320, 568) + Window.size = (dp(320), dp(568)) def narrow_screen(self): - assert self.editor.ids.body.width == 320 - assert self.editor.ids.body.height > 300 + 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")) diff --git a/theme.py b/theme.py index e14ec2e..7c05ab1 100644 --- a/theme.py +++ b/theme.py @@ -6,6 +6,7 @@ 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) From fb28aaeda255c9e3cea71d63489bde8fe884aa83 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 09:10:47 +0100 Subject: [PATCH 18/29] Keep phone and emulator builds in separate concurrency groups --- .github/workflows/build-apk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 817bdb7..0873fbf 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -15,7 +15,7 @@ on: permissions: contents: read concurrency: - group: apk-${{ github.ref }} + group: apk-${{ github.ref }}-${{ inputs.architecture || 'arm64-v8a' }} cancel-in-progress: true jobs: checks: From 3ebd0e0e710adbd67fce5a8c5c66ee1c0229ad08 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 09:15:45 +0100 Subject: [PATCH 19/29] Anchor note list above footer and check save error typography --- images/purenote-list.png | Bin 16165 -> 16209 bytes purenote_ui.kv | 2 ++ tests/ui_smoke.py | 12 ++++++++++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/images/purenote-list.png b/images/purenote-list.png index 35d7f1b18ecac906162a5e15b0d60337cb029d14..1b4c6f765661e2f212b3d3c6b0ebaa4c6433bf0e 100644 GIT binary patch literal 16209 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 CUGP!> literal 16165 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 B`^o?S diff --git a/purenote_ui.kv b/purenote_ui.kv index 8b0e144..a68d102 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -206,6 +206,7 @@ FloatLayout: RecycleView: id: notes + pos_hint: {'x': 0, 'y': 0} viewclass: 'NoteRow' bar_width: dp(3) RecycleBoxLayout: @@ -216,6 +217,7 @@ orientation: 'vertical' Label: id: empty + pos_hint: {'x': 0, 'y': 0} text: root.empty_message font_name: t.FONT_BODY font_size: '17sp' diff --git a/tests/ui_smoke.py b/tests/ui_smoke.py index 009ff95..6ae9ab9 100644 --- a/tests/ui_smoke.py +++ b/tests/ui_smoke.py @@ -33,6 +33,7 @@ def on_start(self): self.autosaved, self.editor_capture, self.failure_and_retry, + self.save_error_capture, self.navigation, self.list_capture, self.trash_and_restore, @@ -81,7 +82,7 @@ def editor_capture(self): self.root.export_to_png(str(OUTPUT / "editor.png")) def failure_and_retry(self): - real_save = self.store.save_note + self.real_save = self.store.save_note def fail(*_): raise sqlite3.OperationalError("simulated disk full") @@ -93,7 +94,11 @@ def fail(*_): assert self.session.dirty assert self.editor.save_error self._popup.dismiss() - self.store.save_note = real_save + + 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." ) @@ -110,6 +115,9 @@ def navigation(self): assert len(self.store.list_notes()) == 2 def list_capture(self): + notes = self.root.get_screen("list").ids.notes + assert notes.pos == notes.parent.pos + assert notes.top == notes.parent.top self.root.export_to_png(str(OUTPUT / "list.png")) self.set_query("DOMANI") self.refresh_list() From afc70e50188c6440ab8bb9323a0b6a4ed50d186c Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 09:20:35 +0100 Subject: [PATCH 20/29] Keep dialogs above keyboard and verify visible popup bounds --- main.py | 18 +++++++++++++++--- tests/ui_smoke.py | 8 ++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 37cd7b5..5a7f4d9 100644 --- a/main.py +++ b/main.py @@ -86,6 +86,16 @@ 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]) @@ -286,7 +296,7 @@ def message_label(text: str, *, scrollable=False) -> Label: def _dialog(self, title: str, content, *, height=0.65, preferred_height=None) -> Popup: if self._popup: self._popup.dismiss() - popup = Popup( + popup = InsetPopup( title=title, content=content, size_hint=(None, None), @@ -306,10 +316,11 @@ def fit(*_): 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.center = ( + popup.safe_center = [ left + (Window.width - left - right) / 2, bottom + (Window.height - top - bottom) / 2, - ) + ] + popup.center = popup.safe_center def dismissed(*_): Window.unbind(size=fit) @@ -320,6 +331,7 @@ def dismissed(*_): Window.bind(size=fit) self.bind(insets=fit) popup.bind(on_dismiss=dismissed) + popup.bind(on_open=fit) fit() self._popup = popup popup.open() diff --git a/tests/ui_smoke.py b/tests/ui_smoke.py index 6ae9ab9..b9bbd53 100644 --- a/tests/ui_smoke.py +++ b/tests/ui_smoke.py @@ -39,6 +39,7 @@ def on_start(self): self.trash_and_restore, self.menu_capture, self.keyboard_layout, + self.keyboard_dialog, self.narrow_screen, self.counts_dialog, self.counts_capture, @@ -145,6 +146,13 @@ def keyboard_layout(self): 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): + assert self._popup.y >= self.insets[3] + assert self._popup.top <= Window.height - self.insets[1] + self._popup.export_to_png(str(OUTPUT / "keyboard-dialog.png")) + self._popup.dismiss() self.insets = [0, 0, 0, 0] Window.size = (dp(320), dp(568)) From 6028e33740e45e36fb6899f3e339f9ccc7a0f930 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 09:52:10 +0100 Subject: [PATCH 21/29] Fix Android density and native nonlinear font scaling across devices --- .github/workflows/test.yml | 4 +-- README.md | 5 +++- RELEASE.md | 4 +-- buildozer.spec | 4 +-- main.py | 14 +++++++--- purenote_ui.kv | 54 +++++++++++++++++++------------------- tests/test_scaling.py | 41 +++++++++++++++++++++++++++++ tests/ui_smoke.py | 10 +++++-- theme.py | 38 +++++++++++++++++++++++++++ 9 files changed, 134 insertions(+), 40 deletions(-) create mode 100644 tests/test_scaling.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 266dcac..442ff12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,12 +26,12 @@ jobs: LIBGL_ALWAYS_SOFTWARE: '1' PURENOTE_SCREENSHOTS: ${{ runner.temp }}/purenote-preview run: xvfb-run -a python tests/ui_smoke.py - - name: Check Android density and enlarged text + - 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: '1.5' + 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 diff --git a/README.md b/README.md index 428ad72..dbbf26b 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,11 @@ 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 150%, con verifiche +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 diff --git a/RELEASE.md b/RELEASE.md index 1758810..9f6b56c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -38,8 +38,8 @@ background/riapertura, aggiornamento con dati esistenti, selettore file, condivisione, backup e ripristino. Verifica poi i controlli della Play Console. Ogni nuovo upload richiede un `android.numeric_version` strettamente maggiore -in `buildozer.spec`. Questa revisione usa `version = 3.0.1` e -`android.numeric_version = 10217`, successivo al precedente 10216. +in `buildozer.spec`. Questa revisione usa `version = 3.0.2` e +`android.numeric_version = 10218`, successivo al precedente 10217. ## Compilazione locale facoltativa diff --git a/buildozer.spec b/buildozer.spec index def31e1..c36ffa2 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -7,8 +7,8 @@ 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,__pycache__,PureNote_export,.pytest_cache,.ruff_cache -version = 3.0.1 -android.numeric_version = 10217 +version = 3.0.2 +android.numeric_version = 10218 # 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 diff --git a/main.py b/main.py index 5a7f4d9..3d1ab9f 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,7 @@ from kivy.core.window import Window from kivy.lang import Builder from kivy.logger import Logger -from kivy.metrics import dp, sp +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 @@ -100,6 +100,8 @@ class PureNoteApp(App): insets = ListProperty([0, 0, 0, 0]) def build(self): + if platform == "android": + theme.configure_android_scaling() self.title = "PureNote" self.icon = str(BASE_DIR / "logo.png") Window.clearcolor = theme.BG @@ -277,7 +279,7 @@ def message_label(text: str, *, scrollable=False) -> Label: label = Label( text=text, font_name=theme.FONT_BODY, - font_size="16sp", + font_size=theme.font(16), color=theme.TEXT, halign="left", valign="middle", @@ -305,7 +307,7 @@ def _dialog(self, title: str, content, *, height=0.65, preferred_height=None) -> separator_color=theme.LINE, title_font=theme.FONT_BODY, title_color=theme.ACCENT, - title_size="18sp", + title_size=theme.font(18), title_align="left", padding=dp(16), ) @@ -320,6 +322,10 @@ def fit(*_): 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(*_): @@ -355,7 +361,7 @@ def _actions(self, title: str, actions: list[tuple[str, Callable]], message=""): items.bind(minimum_height=items.setter("height")) scroll.add_widget(items) box.add_widget(scroll) - button_height = max(dp(48), sp(15) + dp(28)) + 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 diff --git a/purenote_ui.kv b/purenote_ui.kv index a68d102..fdaa79a 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -1,15 +1,15 @@ #:import t theme #:import dp kivy.metrics.dp -#:import sp kivy.metrics.sp +#:import font theme.font #:import NoTransition kivy.uix.screenmanager.NoTransition : font_name: t.FONT_BODY - font_size: '15sp' + 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), sp(15) + dp(28)) + height: max(dp(48), font(15) + dp(28)) text_size: max(0, self.width - dp(24)), None halign: 'center' valign: 'middle' @@ -53,13 +53,13 @@ cursor_width: dp(2) selection_color: (0.3, 0.6, 0.4, 0.35) font_name: t.FONT_BODY - font_size: '18sp' + font_size: font(18) padding: [dp(24), dp(20)] write_tab: False : font_name: t.FONT_BODY - font_size: '12sp' + font_size: font(12) color: t.MUTED halign: 'left' valign: 'middle' @@ -69,7 +69,7 @@ : size_hint_y: None - height: max(dp(48), sp(15) + dp(28)) + dp(24) + height: max(dp(48), font(15) + dp(28)) + dp(24) padding: [dp(16), dp(12)] spacing: dp(12) @@ -98,14 +98,14 @@ caption: '' orientation: 'vertical' size_hint_y: None - height: max(dp(46), sp(22) + sp(16) + dp(4)) + height: max(dp(46), font(22) + font(16) + dp(4)) spacing: dp(4) Label: text: root.value size_hint_y: None - height: sp(22) + height: font(22) font_name: t.FONT_MONO - font_size: '15sp' + font_size: font(15) color: t.TEXT text_size: max(0, self.width - dp(8)), None shorten: True @@ -114,9 +114,9 @@ Label: text: root.caption size_hint_y: None - height: sp(16) + height: font(16) font_name: t.FONT_BODY - font_size: '11sp' + font_size: font(11) color: t.MUTED text_size: max(0, self.width - dp(8)), None shorten: True @@ -126,7 +126,7 @@ : orientation: 'vertical' size_hint_y: None - height: max(dp(112), sp(24) + sp(22) + sp(16) + dp(44)) + height: max(dp(112), font(24) + font(22) + font(16) + dp(44)) padding: [dp(24), dp(16)] spacing: dp(6) canvas.before: @@ -143,7 +143,7 @@ Label: text: root.title font_name: t.FONT_BODY - font_size: '18sp' + font_size: font(18) color: t.TEXT halign: 'left' valign: 'middle' @@ -151,16 +151,16 @@ shorten: True shorten_from: 'right' size_hint_y: None - height: sp(24) + height: font(24) QuietLabel: text: root.snippet if root.snippet else 'Nessun altro testo' - font_size: '14sp' + font_size: font(14) QuietLabel: text: root.date font_name: t.FONT_MONO - font_size: '11sp' + font_size: font(11) size_hint_y: None - height: sp(16) + height: font(16) : BoxLayout: @@ -179,11 +179,11 @@ spacing: dp(16) BoxLayout: size_hint_y: None - height: max(dp(36), sp(25) + dp(8)) + height: max(dp(36), font(25) + dp(8)) Label: text: 'Le tue note' font_name: t.FONT_BODY - font_size: '25sp' + font_size: font(25) color: t.TEXT text_size: self.width, None halign: 'left' @@ -197,7 +197,7 @@ id: search hint_text: 'Cerca nelle note' multiline: False - font_size: '16sp' + font_size: font(16) size_hint_y: None height: max(dp(52), self.minimum_height) padding: [dp(16), dp(16)] @@ -210,7 +210,7 @@ viewclass: 'NoteRow' bar_width: dp(3) RecycleBoxLayout: - default_size: None, max(dp(112), sp(24) + sp(22) + sp(16) + dp(44)) + 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 @@ -220,7 +220,7 @@ pos_hint: {'x': 0, 'y': 0} text: root.empty_message font_name: t.FONT_BODY - font_size: '17sp' + font_size: font(17) color: t.MUTED halign: 'center' valign: 'middle' @@ -236,7 +236,7 @@ text: 'Nota nel cestino · Annulla' disabled: True opacity: 0 if self.disabled else 1 - height: 0 if self.disabled else max(dp(48), sp(15) + dp(28)) + height: 0 if self.disabled else max(dp(48), font(15) + dp(28)) on_release: app.undo_trash() QuietButton: text: '+ Nuova nota' @@ -273,7 +273,7 @@ spacing: dp(8) BoxLayout: size_hint_y: None - height: max(dp(46), sp(22) + sp(16) + dp(4)) + height: max(dp(46), font(22) + font(16) + dp(4)) Counter: value: root.words caption: 'parole' @@ -286,15 +286,15 @@ QuietLabel: text: root.status if not root.save_error else '' size_hint_y: None - height: 0 if root.save_error else max(dp(24), sp(12) + dp(8)) + height: 0 if root.save_error else max(dp(24), font(12) + dp(8)) halign: 'center' QuietButton: text: 'Salvataggio fallito · Riprova' - font_size: '13sp' + font_size: font(13) danger: True disabled: not root.save_error opacity: 1 if root.save_error else 0 - height: max(dp(48), sp(13) + dp(28)) 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: 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/ui_smoke.py b/tests/ui_smoke.py index b9bbd53..536a0af 100644 --- a/tests/ui_smoke.py +++ b/tests/ui_smoke.py @@ -149,9 +149,15 @@ def keyboard_layout(self): self.explain_counts() def keyboard_dialog(self): - assert self._popup.y >= self.insets[3] - assert self._popup.top <= Window.height - self.insets[1] 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)) diff --git a/theme.py b/theme.py index 7c05ab1..86f27de 100644 --- a/theme.py +++ b/theme.py @@ -2,6 +2,7 @@ from pathlib import Path +from kivy.metrics import Metrics, sp from kivy.resources import resource_find BG = (0.047, 0.071, 0.059, 1) @@ -15,3 +16,40 @@ 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), + ) From 5bee362e890936be0135a38dfe9c84b7e60a7f65 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 09:57:52 +0100 Subject: [PATCH 22/29] Size note total from its text at large accessibility scales --- purenote_ui.kv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/purenote_ui.kv b/purenote_ui.kv index fdaa79a..23721d4 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -181,7 +181,7 @@ size_hint_y: None height: max(dp(36), font(25) + dp(8)) Label: - text: 'Le tue note' + text: 'Note' font_name: t.FONT_BODY font_size: font(25) color: t.TEXT @@ -190,6 +190,7 @@ shorten: True QuietLabel: text: root.summary + text_size: None, None size_hint_x: None width: max(dp(56), self.texture_size[0]) halign: 'right' From 295551ff448a4164bd6a648ca2acbfe28d8a604b Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 10:00:36 +0100 Subject: [PATCH 23/29] Keep note total readable with native accessibility fonts --- purenote_ui.kv | 6 +++++- tests/ui_smoke.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/purenote_ui.kv b/purenote_ui.kv index 23721d4..ed7c09e 100644 --- a/purenote_ui.kv +++ b/purenote_ui.kv @@ -188,8 +188,12 @@ text_size: self.width, None halign: 'left' shorten: True - QuietLabel: + 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]) diff --git a/tests/ui_smoke.py b/tests/ui_smoke.py index 536a0af..3a049c9 100644 --- a/tests/ui_smoke.py +++ b/tests/ui_smoke.py @@ -116,9 +116,12 @@ def navigation(self): assert len(self.store.list_notes()) == 2 def list_capture(self): - notes = self.root.get_screen("list").ids.notes + 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() From 0f6ce2e2f18b755e58414ecfbcdb531cb0bcb154 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 10:15:01 +0100 Subject: [PATCH 24/29] Sign debug artifacts with an explicit persistent key and verify identity --- .github/workflows/build-apk.yml | 13 +++++-- README.md | 14 ++++--- tests/sign_apk.py | 67 +++++++++++++++++++++++++++++++++ tests/verify_apk.py | 8 +++- 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 tests/sign_apk.py diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 0873fbf..e74f464 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -38,13 +38,17 @@ jobs: ANDROID_DEBUG_KEYSTORE_B64: ${{ secrets.ANDROID_DEBUG_KEYSTORE_B64 }} run: | if [ -n "$ANDROID_DEBUG_KEYSTORE_B64" ]; then - mkdir -p "$HOME/.android" - printf '%s' "$ANDROID_DEBUG_KEYSTORE_B64" | base64 --decode > "$HOME/.android/debug.keystore" - chmod 600 "$HOME/.android/debug.keystore" + 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 2>&1 | tee build.log + - 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 @@ -68,3 +72,6 @@ jobs: ~/.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/README.md b/README.md index dbbf26b..03b7118 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,15 @@ 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, compilazioni parziali e chiave debug. Per una chiave debug -stabile anche dopo la scadenza della cache, puoi impostare il secret opzionale -`ANDROID_DEBUG_KEYSTORE_B64`. L’APK debug è per le prove: per aggiornare -un’installazione già presente occorre una firma compatibile, oppure usare -il backup prima di reinstallare. +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. 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/verify_apk.py b/tests/verify_apk.py index 405e269..1b637cc 100644 --- a/tests/verify_apk.py +++ b/tests/verify_apk.py @@ -6,11 +6,15 @@ from pathlib import Path -def verify_apk(apk: Path, sdk: Path) -> None: +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}") - signer = max(candidates, key=lambda path: tuple(map(int, re.findall(r"\d+", path.parent.name)))) + return max(candidates, key=lambda path: tuple(map(int, re.findall(r"\d+", path.parent.name)))) + + +def verify_apk(apk: Path, sdk: Path) -> None: + signer = find_signer(sdk) subprocess.run([str(signer), "verify", "--verbose", str(apk)], check=True) manifest = subprocess.check_output( [str(signer.parent / "aapt"), "dump", "badging", str(apk)], text=True From db8594a14f17e32c3684c0b26f8ffb9f0570e77a Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 17:21:24 +0100 Subject: [PATCH 25/29] Add approved PureNote icon and verify release signing identity --- .github/workflows/build-release.yml | 27 ++++++++------ .gitignore | 13 +++++++ RELEASE.md | 36 ++++++++++++++++-- artwork/README.md | 24 ++++++++++++ artwork/icon-background.png | Bin 0 -> 2203 bytes artwork/icon-background.svg | 1 + artwork/icon-foreground.png | Bin 0 -> 5597 bytes artwork/icon-foreground.svg | 10 +++++ artwork/icon.svg | 11 ++++++ buildozer.spec | 7 +++- logo.png | Bin 159032 -> 49546 bytes tests/verify_apk.py | 22 ++++++++++- tests/verify_signing.py | 55 ++++++++++++++++++++++++++++ 13 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 artwork/README.md create mode 100644 artwork/icon-background.png create mode 100644 artwork/icon-background.svg create mode 100644 artwork/icon-foreground.png create mode 100644 artwork/icon-foreground.svg create mode 100644 artwork/icon.svg create mode 100644 tests/verify_signing.py diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 47cdcdf..e3ecd3a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -27,19 +27,24 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} run: | - if [ -n "$ANDROID_KEYSTORE_B64" ]; then - printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/release.keystore" - export P4A_RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore" - export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" - export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" - export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" - echo 'artifact=purenote-release-aab-signed' >> "$GITHUB_OUTPUT" - else - echo '::warning::No signing key configured: this AAB cannot be uploaded to Play.' - echo 'artifact=purenote-release-aab-unsigned' >> "$GITHUB_OUTPUT" - fi + for variable in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD ANDROID_UPLOAD_CERT_SHA256; do + if [ -z "${!variable}" ]; then + echo "::error::Missing $variable; release requires the existing Play upload key and a confirmed certificate." + exit 1 + fi + done + umask 077 + printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/release.keystore" + export P4A_RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore" + export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" + export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" + export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" + python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" + echo 'artifact=purenote-release-aab-signed' >> "$GITHUB_OUTPUT" buildozer -v android release 2>&1 | tee build.log + python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" bin/*.aab - name: Remove temporary signing key if: always() run: rm -f "$RUNNER_TEMP/release.keystore" diff --git a/.gitignore b/.gitignore index 921f5d5..9ca04fd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,16 @@ bin/ *.keystore *.jks build.log +*.aab +*.apk +*.apks +*.idsig +.env +.env.* +*.pem +*.p12 +*.pfx +secrets/ +credentials*.json +*service-account*.json +notes.db.* diff --git a/RELEASE.md b/RELEASE.md index 9f6b56c..f48afeb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -24,13 +24,38 @@ Conserva il file originale e le credenziali fuori dal repository. Il workflow decodifica la chiave in una cartella temporanea del runner e la rimuove anche quando la build fallisce. +Configura anche la variabile del repository `ANDROID_UPLOAD_CERT_SHA256` con +l'impronta SHA-256 del certificato della **chiave di caricamento** già associata +a questa app in Play Console → Integrità dell'app. Non ricavarla soltanto dalla +chiave locale: deve essere confrontata con il dato indipendente della Console. +Il workflow confronta il certificato prima della build e sul bundle finale; +se manca la chiave, manca l'impronta o il confronto fallisce, interrompe il rilascio. + +Le APK di prova 3.0.2 hanno una firma debug, che non va riutilizzata per il rilascio. +Per l'APK ufficiale su GitHub occorre verificare il certificato di **firma dell'app** +già distribuita. Con Play App Signing questo può differire dalla chiave di upload: +un APK firmato soltanto con la chiave di upload non aggiorna un'installazione +firmata da Google. In tal caso si usa un APK firmato fornito da Play, oppure la +chiave originale di firma dell'app se ancora disponibile. Non generare nuove chiavi. + +Verifica l'APK destinato a GitHub prima di pubblicarlo, usando l'impronta +del certificato di **firma dell'app** confermata su Play: + +```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. +Keystore, credenziali, database e file di release restano ignorati da Git; +l'APK ufficiale va allegato alla release GitHub, non aggiunto al codice. +L'AAB rimane un artifact della build, da scaricare per il caricamento successivo. + ## Compilazione Dopo il push autorizzato, apri **Actions → Build PureNote Release AAB → Run workflow**. In alternativa, il workflow parte con un tag `v*`. L’artifact si chiama -`purenote-release-aab-signed` se il keystore è configurato; senza chiave viene -prodotto `purenote-release-aab-unsigned`, utile per verificare la compilazione -ma non caricabile sul Play Store. +`purenote-release-aab-signed` solo dopo la verifica della firma. Non viene prodotto +un bundle unsigned in sostituzione di quello ufficiale. La prima build della toolchain aggiornata va ancora convalidata su GitHub. Prima di pubblicare, prova l’APK su Android: tastiera e barre di sistema, @@ -39,7 +64,10 @@ condivisione, backup e ripristino. Verifica poi i controlli della Play Console. Ogni nuovo upload richiede un `android.numeric_version` strettamente maggiore in `buildozer.spec`. Questa revisione usa `version = 3.0.2` e -`android.numeric_version = 10218`, successivo al precedente 10217. +`android.numeric_version = 10219`, successivo alla 3.0.2 provata (10218). +Questa revisione mantiene il comportamento della 3.0.2 e aggiorna l'icona; +il sorgente vettoriale e i livelli adattivi sono documentati in +[artwork/README.md](artwork/README.md). ## Compilazione locale facoltativa 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 0000000000000000000000000000000000000000..4db4375522ab04d533fb5c58aa052e3b307955e8 GIT binary patch literal 2203 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)el001;Ln>~)y|$6}fC9sT z4VNT2JO9@&jh~|UjaAb4S=@6*hM;@585s`z=V4%Akp${6Vqg$RVPH@=!NA}!fsvs> zfti895olu5sDjaO7)=kO`C_zu7%eA9tAo*M5z;(Zz#kCDsw1utyb;*wVDNPHb6Mw< G&;$Uh9v!Iw literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0a4d668e3d707a31e081c1546e50ed0bc75eb57f GIT binary patch literal 5597 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 literal 0 HcmV?d00001 diff --git a/artwork/icon-foreground.svg b/artwork/icon-foreground.svg new file mode 100644 index 0000000..14b5534 --- /dev/null +++ b/artwork/icon-foreground.svg @@ -0,0 +1,10 @@ + + PureNote + Un foglio verde salvia con angolo ripiegato, due righe di testo e un cursore su fondo verde scuro. + + + + + + + \ No newline at end of file diff --git a/artwork/icon.svg b/artwork/icon.svg new file mode 100644 index 0000000..e9c2df2 --- /dev/null +++ b/artwork/icon.svg @@ -0,0 +1,11 @@ + + PureNote + Un foglio verde salvia con angolo ripiegato, due righe di testo e un cursore su fondo verde scuro. + + + + + + + + diff --git a/buildozer.spec b/buildozer.spec index c36ffa2..c8c42b4 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -6,13 +6,16 @@ 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,__pycache__,PureNote_export,.pytest_cache,.ruff_cache +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 = 10218 +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 diff --git a/logo.png b/logo.png index 3c77dab5d59659b738496d54a1ba60658680ab00..50e0394a3df245921aa904448e6d80f4da8d08ac 100644 GIT binary patch literal 49546 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∾utL7WcdQ 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

Km2gV{-=;A`E}h)|&l-DNJJnmVO14w+FBE5yZ_ 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 Th_H;_00000NkvXXu0mjf9h{C| diff --git a/tests/verify_apk.py b/tests/verify_apk.py index 1b637cc..927286b 100644 --- a/tests/verify_apk.py +++ b/tests/verify_apk.py @@ -5,6 +5,8 @@ import subprocess from pathlib import Path +from verify_signing import fingerprint + def find_signer(sdk: Path) -> Path: candidates = list((sdk / "build-tools").glob("*/apksigner")) @@ -13,9 +15,21 @@ def find_signer(sdk: Path) -> Path: return max(candidates, key=lambda path: tuple(map(int, re.findall(r"\d+", path.parent.name)))) -def verify_apk(apk: Path, sdk: Path) -> None: +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"certificate SHA-256 digest: ([0-9a-f]+)", details) + 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 ) @@ -24,6 +38,8 @@ def verify_apk(apk: Path, sdk: Path) -> None: 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) @@ -33,6 +49,8 @@ def verify_apk(apk: Path, sdk: Path) -> None: 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, @@ -40,4 +58,4 @@ def verify_apk(apk: Path, sdk: Path) -> None: ) args = parser.parse_args() for apk in args.apks: - verify_apk(apk, args.sdk) + 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) From 13dcba820a6a8dc2dbd6c09af98ef5c4f8bc6680 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 17:38:59 +0100 Subject: [PATCH 26/29] Allow inspection of existing release certificate without exposing the key --- .github/workflows/build-release.yml | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e3ecd3a..315d708 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,12 +1,47 @@ name: Build PureNote Release AAB on: workflow_dispatch: + inputs: + signing_check_only: + description: 'Read only the public certificate of the existing signing key' + type: boolean + default: false push: tags: ['v*'] permissions: contents: read jobs: + signing-check: + if: inputs.signing_check_only == true + runs-on: ubuntu-24.04 + steps: + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Inspect the existing signing certificate + shell: bash + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + for variable in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS; do + if [ -z "${!variable}" ]; then + echo "::error::Missing $variable" + exit 1 + fi + done + key_path="$RUNNER_TEMP/purenote-inspect.keystore" + cert_path="$RUNNER_TEMP/purenote-public-cert.der" + trap 'rm -f "$key_path" "$cert_path"' EXIT + umask 077 + printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$key_path" + keytool -exportcert -keystore "$key_path" -alias "$ANDROID_KEY_ALIAS" \ + -storepass:env ANDROID_KEYSTORE_PASSWORD -file "$cert_path" + echo "Existing signing certificate SHA-256: $(sha256sum "$cert_path" | cut -d ' ' -f 1)" checks: + if: inputs.signing_check_only != true uses: ./.github/workflows/test.yml build-release: needs: checks From 4d026a2dc9c8a54849ae32634363540858777634 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 17:47:19 +0100 Subject: [PATCH 27/29] Prepare release APK and AAB for local signing with the original key --- .github/workflows/build-release.yml | 50 ++++++++++++++++++++--------- RELEASE.md | 9 ++++++ buildozer.spec | 3 ++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 315d708..3f9eca5 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -6,6 +6,10 @@ on: description: 'Read only the public certificate of the existing signing key' type: boolean default: false + local_signing: + description: 'Build release APK and AAB for signing locally with the original key' + type: boolean + default: false push: tags: ['v*'] permissions: @@ -63,23 +67,32 @@ jobs: ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} + PURENOTE_LOCAL_SIGNING: ${{ inputs.local_signing }} run: | - for variable in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD ANDROID_UPLOAD_CERT_SHA256; do - if [ -z "${!variable}" ]; then - echo "::error::Missing $variable; release requires the existing Play upload key and a confirmed certificate." - exit 1 - fi - done - umask 077 - printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/release.keystore" - export P4A_RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore" - export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" - export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" - export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" - python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" - echo 'artifact=purenote-release-aab-signed' >> "$GITHUB_OUTPUT" + if [ "$PURENOTE_LOCAL_SIGNING" = 'true' ]; then + echo 'artifact=purenote-release-aab-for-local-signing' >> "$GITHUB_OUTPUT" + else + for variable in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD ANDROID_UPLOAD_CERT_SHA256; do + if [ -z "${!variable}" ]; then + echo "::error::Missing $variable; release requires the existing Play upload key and a confirmed certificate." + exit 1 + fi + done + umask 077 + printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/release.keystore" + export P4A_RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore" + export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" + export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" + export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" + python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" + echo 'artifact=purenote-release-aab-signed' >> "$GITHUB_OUTPUT" + fi buildozer -v android release 2>&1 | tee build.log - python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" bin/*.aab + if [ "$PURENOTE_LOCAL_SIGNING" = 'true' ]; then + buildozer --profile release-apk -v android release 2>&1 | tee -a build.log + else + python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" bin/*.aab + fi - name: Remove temporary signing key if: always() run: rm -f "$RUNNER_TEMP/release.keystore" @@ -95,6 +108,13 @@ jobs: with: name: purenote-aab-build-log path: build.log + - name: Upload APK for local signing + if: inputs.local_signing == true + 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 diff --git a/RELEASE.md b/RELEASE.md index f48afeb..97d051d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -50,6 +50,15 @@ Keystore, credenziali, database e file di release restano ignorati da Git; l'APK ufficiale va allegato alla release GitHub, non aggiunto al codice. L'AAB rimane un artifact della build, da scaricare per il caricamento successivo. +Se la password va inserita soltanto nel terminale locale, avvia il workflow con +`local_signing = true`. In questa modalità vengono preparati APK e AAB release +da firmare localmente: gli artifact si chiamano +`purenote-release-apk-for-local-signing` e +`purenote-release-aab-for-local-signing`. Non sono file ufficiali già firmati; +prima della pubblicazione bisogna applicare la chiave originale e verificare +nuovamente i certificati. Il profilo Buildozer `release-apk` seleziona l'APK +senza modificare la configurazione AAB predefinita. + ## Compilazione Dopo il push autorizzato, apri **Actions → Build PureNote Release AAB → Run workflow**. diff --git a/buildozer.spec b/buildozer.spec index c8c42b4..d20e524 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -34,6 +34,9 @@ p4a.bootstrap = sdl2 p4a.branch = master p4a.commit = 58d21141f17c889bf8585f5665921d72028f8831 +[app@release-apk] +android.release_artifact = apk + [buildozer] log_level = 2 warn_on_root = 1 From 1d8345926fc76683d17064d4f4d7bfa90865ac55 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 17:53:24 +0100 Subject: [PATCH 28/29] Distinguish app signing certificate from the Google source stamp --- tests/verify_apk.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/verify_apk.py b/tests/verify_apk.py index 927286b..52e6cc6 100644 --- a/tests/verify_apk.py +++ b/tests/verify_apk.py @@ -27,7 +27,11 @@ def verify_apk( details = subprocess.check_output( [str(signer), "verify", "--print-certs", str(apk)], text=True ) - actual = re.findall(r"certificate SHA-256 digest: ([0-9a-f]+)", details) + 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( From 37befc23e08001a7488bcb41a52cfb94ec186b50 Mon Sep 17 00:00:00 2001 From: bbnss Date: Tue, 27 Oct 2026 18:20:34 +0100 Subject: [PATCH 29/29] Keep signing keys out of CI and fix pip environment reuse between ABIs --- .github/actions/android-build/action.yml | 31 ++++++ .github/workflows/build-apk.yml | 8 +- .github/workflows/build-release.yml | 89 +--------------- README.md | 3 +- RELEASE.md | 130 +++++++++-------------- buildozer.spec | 2 + 6 files changed, 93 insertions(+), 170 deletions(-) diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml index 1f2c539..a8edc4d 100644 --- a/.github/actions/android-build/action.yml +++ b/.github/actions/android-build/action.yml @@ -47,3 +47,34 @@ runs: 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 index e74f464..3840608 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -45,7 +45,7 @@ jobs: fi - name: Build debug APK shell: bash - run: buildozer -v android debug 2>&1 | tee build.log + 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 @@ -57,12 +57,6 @@ jobs: name: purenote-debug-apk path: bin/*.apk if-no-files-found: error - - name: Upload build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: purenote-apk-build-log - path: build.log - name: Save build progress if: always() uses: actions/cache/save@v4 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 3f9eca5..3d5b63f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,51 +1,12 @@ name: Build PureNote Release AAB on: workflow_dispatch: - inputs: - signing_check_only: - description: 'Read only the public certificate of the existing signing key' - type: boolean - default: false - local_signing: - description: 'Build release APK and AAB for signing locally with the original key' - type: boolean - default: false push: tags: ['v*'] permissions: contents: read jobs: - signing-check: - if: inputs.signing_check_only == true - runs-on: ubuntu-24.04 - steps: - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - name: Inspect the existing signing certificate - shell: bash - env: - ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - run: | - for variable in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS; do - if [ -z "${!variable}" ]; then - echo "::error::Missing $variable" - exit 1 - fi - done - key_path="$RUNNER_TEMP/purenote-inspect.keystore" - cert_path="$RUNNER_TEMP/purenote-public-cert.der" - trap 'rm -f "$key_path" "$cert_path"' EXIT - umask 077 - printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$key_path" - keytool -exportcert -keystore "$key_path" -alias "$ANDROID_KEY_ALIAS" \ - -storepass:env ANDROID_KEYSTORE_PASSWORD -file "$cert_path" - echo "Existing signing certificate SHA-256: $(sha256sum "$cert_path" | cut -d ' ' -f 1)" checks: - if: inputs.signing_check_only != true uses: ./.github/workflows/test.yml build-release: needs: checks @@ -58,58 +19,18 @@ jobs: - 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 - id: bundle + - name: Build release AAB and APK for local signing shell: bash - env: - ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} - PURENOTE_LOCAL_SIGNING: ${{ inputs.local_signing }} run: | - if [ "$PURENOTE_LOCAL_SIGNING" = 'true' ]; then - echo 'artifact=purenote-release-aab-for-local-signing' >> "$GITHUB_OUTPUT" - else - for variable in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD ANDROID_UPLOAD_CERT_SHA256; do - if [ -z "${!variable}" ]; then - echo "::error::Missing $variable; release requires the existing Play upload key and a confirmed certificate." - exit 1 - fi - done - umask 077 - printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/release.keystore" - export P4A_RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore" - export P4A_RELEASE_KEYSTORE_PASSWD="$ANDROID_KEYSTORE_PASSWORD" - export P4A_RELEASE_KEYALIAS="$ANDROID_KEY_ALIAS" - export P4A_RELEASE_KEYALIAS_PASSWD="$ANDROID_KEY_PASSWORD" - python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" - echo 'artifact=purenote-release-aab-signed' >> "$GITHUB_OUTPUT" - fi - buildozer -v android release 2>&1 | tee build.log - if [ "$PURENOTE_LOCAL_SIGNING" = 'true' ]; then - buildozer --profile release-apk -v android release 2>&1 | tee -a build.log - else - python tests/verify_signing.py --expected-sha256 "$ANDROID_UPLOAD_CERT_SHA256" bin/*.aab - fi - - name: Remove temporary signing key - if: always() - run: rm -f "$RUNNER_TEMP/release.keystore" - - name: Upload AAB + buildozer -v android release + buildozer --profile release-apk -v android release + - name: Upload AAB for local signing uses: actions/upload-artifact@v4 with: - name: ${{ steps.bundle.outputs.artifact }} + name: purenote-release-aab-for-local-signing path: bin/*.aab if-no-files-found: error - - name: Upload build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: purenote-aab-build-log - path: build.log - name: Upload APK for local signing - if: inputs.local_signing == true uses: actions/upload-artifact@v4 with: name: purenote-release-apk-for-local-signing diff --git a/README.md b/README.md index 03b7118..7161663 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ Il workflow **Build PureNote APK** esegue test della logica, lint e prova UI, po 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 log e anteprime dell’interfaccia. +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. diff --git a/RELEASE.md b/RELEASE.md index 97d051d..c8bbd75 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,96 +1,70 @@ -# AAB di PureNote +# Release PureNote -Il workflow **Build PureNote Release AAB** prepara il bundle per il Play Store. -Esegue gli stessi controlli dell’APK e compila per `arm64-v8a` e `armeabi-v7a`. -Non pubblica automaticamente il bundle. +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. -## Firma +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. -Per aggiornare l’app già pubblicata, usa il keystore di upload esistente e mantieni -l’application ID `bbnss.test.purenote`. Non sostituire la chiave con una nuova -senza aver completato l’eventuale procedura di cambio chiave nella Play Console. +## 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` -Configura questi repository secret in **Settings → Secrets and variables → Actions**: +Questi file non sono ancora ufficiali: vanno firmati e verificati prima di distribuirli. +I log diagnostici grezzi non vengono caricati come artifact. -| Secret | Contenuto | -| --- | --- | -| `ANDROID_KEYSTORE_B64` | File keystore codificato in base64 | -| `ANDROID_KEYSTORE_PASSWORD` | Password del keystore | -| `ANDROID_KEY_ALIAS` | Alias della chiave | -| `ANDROID_KEY_PASSWORD` | Password della chiave | +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. -Su Linux puoi produrre la codifica con `base64 -w 0 percorso/chiave.keystore`. -Conserva il file originale e le credenziali fuori dal repository. -Il workflow decodifica la chiave in una cartella temporanea del runner e la rimuove -anche quando la build fallisce. +## Firma locale e certificati -Configura anche la variabile del repository `ANDROID_UPLOAD_CERT_SHA256` con -l'impronta SHA-256 del certificato della **chiave di caricamento** già associata -a questa app in Play Console → Integrità dell'app. Non ricavarla soltanto dalla -chiave locale: deve essere confrontata con il dato indipendente della Console. -Il workflow confronta il certificato prima della build e sul bundle finale; -se manca la chiave, manca l'impronta o il confronto fallisce, interrompe il rilascio. +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. -Le APK di prova 3.0.2 hanno una firma debug, che non va riutilizzata per il rilascio. -Per l'APK ufficiale su GitHub occorre verificare il certificato di **firma dell'app** -già distribuita. Con Play App Signing questo può differire dalla chiave di upload: -un APK firmato soltanto con la chiave di upload non aggiorna un'installazione -firmata da Google. In tal caso si usa un APK firmato fornito da Play, oppure la -chiave originale di firma dell'app se ancora disponibile. Non generare nuove chiavi. +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 +``` -Verifica l'APK destinato a GitHub prima di pubblicarlo, usando l'impronta -del certificato di **firma dell'app** confermata su Play: +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. -Keystore, credenziali, database e file di release restano ignorati da Git; -l'APK ufficiale va allegato alla release GitHub, non aggiunto al codice. -L'AAB rimane un artifact della build, da scaricare per il caricamento successivo. - -Se la password va inserita soltanto nel terminale locale, avvia il workflow con -`local_signing = true`. In questa modalità vengono preparati APK e AAB release -da firmare localmente: gli artifact si chiamano -`purenote-release-apk-for-local-signing` e -`purenote-release-aab-for-local-signing`. Non sono file ufficiali già firmati; -prima della pubblicazione bisogna applicare la chiave originale e verificare -nuovamente i certificati. Il profilo Buildozer `release-apk` seleziona l'APK -senza modificare la configurazione AAB predefinita. +Il controllo rifiuta firme diverse, APK debuggable e permessi Android; distingue +il certificato dell'app dall'eventuale source stamp di Google. -## Compilazione +Verifica anche firma e integrità del bundle: -Dopo il push autorizzato, apri **Actions → Build PureNote Release AAB → Run workflow**. -In alternativa, il workflow parte con un tag `v*`. L’artifact si chiama -`purenote-release-aab-signed` solo dopo la verifica della firma. Non viene prodotto -un bundle unsigned in sostituzione di quello ufficiale. - -La prima build della toolchain aggiornata va ancora convalidata su GitHub. -Prima di pubblicare, prova l’APK su Android: tastiera e barre di sistema, -background/riapertura, aggiornamento con dati esistenti, selettore file, -condivisione, backup e ripristino. Verifica poi i controlli della Play Console. - -Ogni nuovo upload richiede un `android.numeric_version` strettamente maggiore -in `buildozer.spec`. Questa revisione usa `version = 3.0.2` e -`android.numeric_version = 10219`, successivo alla 3.0.2 provata (10218). -Questa revisione mantiene il comportamento della 3.0.2 e aggiorna l'icona; -il sorgente vettoriale e i livelli adattivi sono documentati in -[artwork/README.md](artwork/README.md). - -## Compilazione locale facoltativa - -Con le dipendenze di sistema elencate nell’action Android condivisa: - -```bash -python3.11 -m venv .build-venv -.build-venv/bin/python -m pip install -r requirements-build.txt -export PIP_CONSTRAINT="$PWD/constraints-android.txt" -.build-venv/bin/buildozer -v android debug -.build-venv/bin/buildozer -v android release +```sh +python tests/verify_signing.py --expected-sha256 "$PLAY_UPLOAD_CERT_SHA256" percorso/PureNote.aab +java -jar bundletool.jar validate --bundle=percorso/PureNote.aab ``` -Il release locale richiede le variabili `P4A_RELEASE_KEYSTORE`, -`P4A_RELEASE_KEYSTORE_PASSWD`, `P4A_RELEASE_KEYALIAS` e `P4A_RELEASE_KEYALIAS_PASSWD` -per firmare il bundle. Le architetture locali sono quelle di `buildozer.spec`; -il workflow GitHub seleziona entrambe le ABI per il release. +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/buildozer.spec b/buildozer.spec index d20e524..1133a91 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -33,6 +33,8 @@ 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