From 63b2fb6370b29f87e7e296f043e4c184cf129d01 Mon Sep 17 00:00:00 2001 From: Massimiliano D'Acunzo Date: Wed, 7 Oct 2026 22:02:06 +0100 Subject: [PATCH 1/3] chore(fdroid): point the recipe mirror at v0.2.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ea5781b-c84c-47b9-8f99-781002da3818 --- metadata/com.privee.app.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/metadata/com.privee.app.yml b/metadata/com.privee.app.yml index 6240fdd..2cc16ac 100644 --- a/metadata/com.privee.app.yml +++ b/metadata/com.privee.app.yml @@ -14,9 +14,9 @@ Repo: https://github.com/MaxDac/PriveeApp.git Binaries: https://github.com/MaxDac/PriveeApp/releases/download/v%v/Privee-%v.apk Builds: - - versionName: 0.1.0 - versionCode: 1 - commit: v0.1.0 + - versionName: 0.2.0 + versionCode: 2 + commit: v0.2.0 subdir: app sudo: - apt-get update @@ -34,5 +34,5 @@ AllowedAPKSigningKeys: ea586e3f2deaf1ff3c507f8eae4ce3363c9f892d787da19d0003817ee AutoUpdateMode: Version UpdateCheckMode: Tags ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ UpdateCheckData: version.properties|versionCode=(\d+)|.|versionName=(\S+) -CurrentVersion: 0.1.0 -CurrentVersionCode: 1 +CurrentVersion: 0.2.0 +CurrentVersionCode: 2 From e92f5e0953ae7877902a12ef3d8dfb70d4c11bef Mon Sep 17 00:00:00 2001 From: Massimiliano D'Acunzo Date: Thu, 8 Oct 2026 09:12:15 +0100 Subject: [PATCH 2/3] docs: add agent knowledge base, architecture and technology docs, and skills Add docs/TECHNOLOGIES.md (full stack breakdown, notifications, native libsignal) and docs/ARCHITECTURE.md (modules, data flow, where to change what, reproducibility-sensitive changes). Add hand-written CLAUDE.md and .github/copilot-instructions.md, and four skills shipped for both tools: libsignal-upgrade, dependency-upgrade, store-screenshots and cross-repo-change. Generalise the skill-copy test to every skill, and check front matter and relative links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ea5781b-c84c-47b9-8f99-781002da3818 --- .claude/skills/cross-repo-change/SKILL.md | 66 +++++++++ .claude/skills/dependency-upgrade/SKILL.md | 75 +++++++++++ .claude/skills/fdroid-release/SKILL.md | 2 + .claude/skills/libsignal-upgrade/SKILL.md | 94 +++++++++++++ .claude/skills/store-screenshots/SKILL.md | 102 ++++++++++++++ .github/copilot-instructions.md | 71 ++++++++++ .github/skills/cross-repo-change/SKILL.md | 66 +++++++++ .github/skills/dependency-upgrade/SKILL.md | 75 +++++++++++ .github/skills/fdroid-release/SKILL.md | 2 + .github/skills/libsignal-upgrade/SKILL.md | 94 +++++++++++++ .github/skills/store-screenshots/SKILL.md | 102 ++++++++++++++ CLAUDE.md | 75 +++++++++++ README.md | 14 ++ docs/ARCHITECTURE.md | 148 +++++++++++++++++++++ docs/TECHNOLOGIES.md | 109 +++++++++++++++ scripts/tests/test_skill_copies.py | 49 +++++-- 16 files changed, 1133 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/cross-repo-change/SKILL.md create mode 100644 .claude/skills/dependency-upgrade/SKILL.md create mode 100644 .claude/skills/libsignal-upgrade/SKILL.md create mode 100644 .claude/skills/store-screenshots/SKILL.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/skills/cross-repo-change/SKILL.md create mode 100644 .github/skills/dependency-upgrade/SKILL.md create mode 100644 .github/skills/libsignal-upgrade/SKILL.md create mode 100644 .github/skills/store-screenshots/SKILL.md create mode 100644 CLAUDE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/TECHNOLOGIES.md diff --git a/.claude/skills/cross-repo-change/SKILL.md b/.claude/skills/cross-repo-change/SKILL.md new file mode 100644 index 0000000..88ed740 --- /dev/null +++ b/.claude/skills/cross-repo-change/SKILL.md @@ -0,0 +1,66 @@ +--- +name: cross-repo-change +description: Make a change that spans the Privee server (MaxDac/Privee) and the Android app (MaxDac/PriveeApp, com.privee.app). Covers the shared contract (REST under /api/app, Phoenix channels, api_version, push payload, libsignal version), the safe order of work (server first and backward compatible, deploy, then app release, then F-Droid), app-side implementation in core/net, core/signal and app, tests, and compatibility with older app versions still installed from F-Droid. Use when a feature, fix or refactor needs both repos, when the server API or channel events change, when bumping api_version, or when asked how the app and server fit together. +--- + +# Change both repos + +Privee is two repositories: +- **MaxDac/Privee**: the Phoenix server and the website, with a libsignal WASM client. +- **MaxDac/PriveeApp**: this Android app, with a libsignal Android client. + +The canonical contract and change order live on the server side: +- : the contract, version rules and release order; +- : endpoints and channel events; +- : the protocol. + +On the server, the matching skill is `api-change`. + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/cross-repo-change/SKILL.md`; +- `.github/skills/cross-repo-change/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## Why order matters + +Server deploys are immediate. App updates reach users days or weeks later, and F-Droid builds lag further, so old app versions stay in use for a long time. Therefore: + +1. **Server first, backward compatible.** + - Add new endpoints, events and fields. + - Keep old ones working. + - New request fields must be optional. + - Never change the meaning of an existing field. +2. **Deploy the server** (`main` → Fly.io, server skill `deploy`). +3. **App change.** Use the new contract, and degrade gracefully on servers that do not have it yet: self-hosted instances update on their own schedule. +4. **App release** (`fdroid-release` skill), then F-Droid picks it up. +5. **Remove the old server behaviour** only after no supported app version uses it. + +A breaking change needs a new `api_version`. The server keeps serving the old one until old apps are gone. The app adds the new version to `ServerInfo.SUPPORTED_API_VERSIONS` and keeps accepting the old one while it still supports such servers. + +## App-side checklist + +1. **Read the server PR and docs first.** Work in a separate clone of the server if you need to read code (`gh repo clone MaxDac/Privee`). +2. **REST:** + - edit `core/net/src/main/kotlin/com/privee/net/PriveeApi.kt`; + - add a MockWebServer test in `PriveeApiTest`; + - decode with `ignoreUnknownKeys`-style tolerance, so the server can add fields. +3. **Channels:** + - incoming events go in `PriveeSession.handle` (topic `session`) or in `ChatConversation` (topic `chat:`); + - calls go through `ServerCall` in `SignalClient`; + - unknown events must be ignored, not treated as errors. +4. **Protocol or key changes** go in `core/signal` and need tests in `SignalClientTest`. Both clients must agree, so check the website's behaviour and libsignal version (`libsignal-upgrade` skill). +5. **`api_version`:** update `ServerInfo.SUPPORTED_API_VERSIONS`, `ServerInfoTest`, and the "Choosing a server" section of the README. +6. **Push:** pushes are wake-ups only. Never add message content, sender names or keys to a push payload, on either side. +7. **Test against a local server.** Run the server's `main` (or the PR branch) and the debug app on an emulator at `http://10.0.2.2:4000`; see the `store-screenshots` skill, step 1. +8. **PR.** + - Use Conventional Commits, for example `feat(chat): …`, and link the server PR. + - The four required checks must pass. + - Do not touch `version.properties`. + +## Invariants (both repos) + +- The server only stores public key material, ciphertext and metadata it needs for routing. It never stores plaintext or private keys. +- Push payloads contain no content. +- Every API change stays compatible with app versions already released, or bumps `api_version`. +- Both repos are AGPL-3.0-only. The server exposes its source URL (`PRIVEE_SOURCE_URL`), and forks must keep doing so. diff --git a/.claude/skills/dependency-upgrade/SKILL.md b/.claude/skills/dependency-upgrade/SKILL.md new file mode 100644 index 0000000..8a95afd --- /dev/null +++ b/.claude/skills/dependency-upgrade/SKILL.md @@ -0,0 +1,75 @@ +--- +name: dependency-upgrade +description: Upgrade Gradle, the Android Gradle Plugin, Kotlin, AndroidX/Compose, OkHttp, kotlinx, UnifiedPush or GitHub Actions in Privee for Android (com.privee.app) without breaking reproducible builds or F-Droid inclusion. Covers Dependabot PRs, the version catalogue, buildserver JDK/SDK/NDK constraints, the native-library allowlist, non-free dependency checks and the Reproducibility workflow. Use when asked to bump, update or upgrade a dependency, plugin, SDK level, Gradle wrapper or workflow action, or to review or merge a Dependabot PR. For libsignal use the libsignal-upgrade skill instead. +--- + +# Upgrade dependencies + +Every published APK must be byte-identical when built by us and by F-Droid, and +must contain no proprietary code. Any dependency change can break either. + +Background reading: +- [`docs/TECHNOLOGIES.md`](../../../docs/TECHNOLOGIES.md): the stack and why each part is there. +- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md): the section on changes that affect reproducible builds. +- [`docs/FDROID_VALIDATION.md`](../../../docs/FDROID_VALIDATION.md): local buildserver builds and diffoscope triage. + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/dependency-upgrade/SKILL.md`; +- `.github/skills/dependency-upgrade/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## Where versions live + +| What | File | +|---|---| +| Libraries and plugins | `gradle/libs.versions.toml` (single source of truth) | +| Gradle | `gradle/wrapper/gradle-wrapper.properties` (use `./gradlew wrapper --gradle-version X`; this also updates the wrapper jar) | +| SDK levels, JVM target | `app/build.gradle.kts`, `core/*/build.gradle.kts` | +| CI SDK packages and JDK | `.github/workflows/ci.yml` (`platforms;android-NN`, `build-tools;X`, Java 17) | +| Actions | `.github/workflows/*.yml`. `reproducibility.yml` and `release.yml` pin actions by SHA, so keep doing that. | +| Buildserver image | `scripts/fdroid-rb-docker.sh` (digest) | +| Release tooling | `PyYAML==6.0.3` in `ci.yml` | + +Dependabot (`.github/dependabot.yml`) opens weekly PRs for Gradle dependencies and Actions. + +## Procedure + +1. **One logical change per PR.** Change one library, or one coupled group (for example AGP plus Gradle, or Kotlin plus Compose compiler), in each PR. Title: `build(deps): bump from A to B`. +2. **Check that the license is free.** + - No Google Play Services, Firebase, GMS or proprietary SDKs. + - Check that new transitive dependencies are not proprietary: `./gradlew :app:dependencies --configuration releaseRuntimeClasspath`. +3. **Check the buildserver can build it.** + - F-Droid builds in `fdroidserver:buildserver-trixie` (Debian trixie, OpenJDK 21) with the SDK packages the recipe requests. + - AGP or Gradle bumps that need a newer JDK, or a `compileSdk` not yet in the buildserver, will fail there even if CI passes. + - If `compileSdk` or build-tools change, update `ci.yml` and the README requirements too. +4. **Build and test locally:** + ```bash + ./gradlew test :app:lintDebug :app:assembleDebug + python3 -m unittest discover -s scripts/tests # WSL on Windows + ``` +5. **Check the native libraries.** + - `scripts/verify_release_apk.py` allows exactly two `.so` files: `lib/arm64-v8a/libsignal_jni.so` and `lib/arm64-v8a/libandroidx.graphics.path.so`. + - If a bump adds a native library, either exclude it in `app/build.gradle.kts` (`packaging.jniLibs.excludes`) or stop and ask. A new prebuilt `.so` is usually not acceptable on F-Droid. +6. **Push the PR and wait for the four required checks:** + - `Android (test, lint, assemble)`; + - `Build (replay, release checks)`; + - `Build (fdroid build)`; + - `compare`. + + If `compare` fails, download the diffoscope artifact and use the triage table in FDROID_VALIDATION.md. +7. **Do not release in the same PR.** Version bumps go through the release-bump PR (skill `fdroid-release`). + +## Special cases + +| Upgrade | Watch out for | +|---|---| +| AGP | DSL changes in `app/build.gradle.kts`; `packaging` and `jniLibs.keepDebugSymbols` behaviour; dex differences between JDKs (the buildserver uses JDK 21) | +| Gradle wrapper | Commit `gradle-wrapper.jar` and the properties file together; `validateDistributionUrl` stays `true` | +| Kotlin | Compose compiler and serialization plugins share `kotlin` in the catalogue | +| Compose BOM | `material-icons-core` is pinned outside the BOM (1.7.8), so leave it there | +| UnifiedPush connector | Re-test push end to end with a distributor (ntfy); check `PriveePushService` API changes | +| OkHttp | `PhoenixSocket` WebSocket behaviour and `mockwebserver3` tests | +| desugar_jdk_libs | Required by libsignal-android; keep `isCoreLibraryDesugaringEnabled` | +| Buildserver image digest | Take it from fdroiddata's CI; rerun the Reproducibility workflow (see FDROID_VALIDATION.md) | +| libsignal | Use the `libsignal-upgrade` skill | diff --git a/.claude/skills/fdroid-release/SKILL.md b/.claude/skills/fdroid-release/SKILL.md index 4475e3a..8c9ec81 100644 --- a/.claude/skills/fdroid-release/SKILL.md +++ b/.claude/skills/fdroid-release/SKILL.md @@ -15,6 +15,8 @@ Background reading, if a step fails: - [`docs/RELEASING.md`](../../../docs/RELEASING.md): Release workflow, one-time setup, recovery. - [`docs/FDROID_VALIDATION.md`](../../../docs/FDROID_VALIDATION.md): local and buildserver builds, diffoscope. - [`libsignal/README.md`](../../../libsignal/README.md): source build of `libsignal_jni.so`, re-pinning. +- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) and [`docs/TECHNOLOGIES.md`](../../../docs/TECHNOLOGIES.md): what changes affect reproducibility, and the stack. +- Related skills: `libsignal-upgrade`, `dependency-upgrade`, `store-screenshots` (refresh store images before a release), `cross-repo-change` (server changes must be deployed before the app release that needs them). This file exists twice and the two copies must stay byte-identical: - `.claude/skills/fdroid-release/SKILL.md` (Claude Code); diff --git a/.claude/skills/libsignal-upgrade/SKILL.md b/.claude/skills/libsignal-upgrade/SKILL.md new file mode 100644 index 0000000..1f51bc7 --- /dev/null +++ b/.claude/skills/libsignal-upgrade/SKILL.md @@ -0,0 +1,94 @@ +--- +name: libsignal-upgrade +description: Upgrade libsignal in Privee for Android (com.privee.app) while keeping release builds reproducible and F-Droid-compatible. Covers the version catalogue, libsignal/source.lock.json (tag, commit, Rust toolchain, NDK, Cargo cfg, prune list), rebuilding libsignal_jni.so from source, re-pinning its SHA-256 from the F-Droid buildserver image, the Reproducibility workflow, and keeping the Privee website's libsignal WASM on the same version. Use when asked to bump, update or upgrade libsignal, change the NDK or Rust toolchain for libsignal, or when verifyLibsignal or the Reproducibility check fails on libsignal_jni.so. +--- + +# Upgrade libsignal + +The app uses libsignal twice: +- in debug builds and CI, from Maven (`org.signal:libsignal-client` and `libsignal-android`); +- in release and F-Droid builds, built from source at a pinned commit (`-PlibsignalBuiltFromSource`). + +Both must use the same version. That version must also match the libsignal WASM build used by the Privee website, because the two clients talk to each other. + +Background reading: +- [`libsignal/README.md`](../../../libsignal/README.md): the source build and the bump checklist. +- [`docs/FDROID_VALIDATION.md`](../../../docs/FDROID_VALIDATION.md): buildserver builds and the diffoscope triage table. +- [`docs/TECHNOLOGIES.md`](../../../docs/TECHNOLOGIES.md): why libsignal is built from source. +- Server side: , the libsignal alignment section. + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/libsignal-upgrade/SKILL.md`; +- `.github/skills/libsignal-upgrade/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## 0. Decide the target version + +```bash +git ls-remote --tags https://github.com/signalapp/libsignal 'refs/tags/v*' | sort -t/ -k3 -V | tail -5 +grep -n libsignal gradle/libs.versions.toml +``` + +Check which libsignal version the Privee website uses (see the cross-repo doc). Ship an upgrade together with the server, or after it. If the protocol changed between versions, read libsignal's release notes and Privee's `docs/e2e-encryption.md` before going ahead. + +## 1. Bump the pins + +1. In `gradle/libs.versions.toml`, set `libsignal = "X.Y.Z"`. +2. In `libsignal/source.lock.json`: + - set `source.tag` to `vX.Y.Z`; + - set `source.commit` to the full SHA from `git ls-remote https://github.com/signalapp/libsignal refs/tags/vX.Y.Z`. Take the peeled `^{}` SHA if the tag is annotated. + - set `rust.toolchain` to the contents of libsignal's `rust-toolchain` file at that tag; + - check `build.ndkRevision`, `build.rustCfg` and `build.features` against libsignal's `java/build_jni.sh` and `.cargo/config.toml` at that tag; + - check that every `preparation.keep` path still exists at that tag. +3. If the NDK revision changes, also update `ndk:` in `metadata/com.privee.app.yml` and in the matching block of `docs/FDROID.md`. + +## 2. Build and test against Maven + +```bash +./gradlew test :app:lintDebug :app:assembleDebug +``` + +This build uses the Maven artifacts. Fix any API changes in `core/signal`, and in `app` where it uses libsignal directly. Then run `SignalClientTest`. + +## 3. Build from source locally (Linux or WSL) + +```bash +NDK_ROOT=/path/to/android-ndk-r28c bash libsignal/scripts/build-libsignal.sh all +./gradlew -PlibsignalBuiltFromSource -PallowUnpinnedLibsignal :app:assembleRelease +``` + +`-PallowUnpinnedLibsignal` is for this step only. Never commit or use it in CI. + +## 4. Re-pin the `.so` hash from the buildserver image + +The hash that matters is the one F-Droid gets, so build in the pinned buildserver image: + +```bash +bash scripts/fdroid-rb-docker.sh out +cat out/libsignal/SHA256SUMS +``` + +Write the `libsignal_jni.so` hash into `build.expectedSha256` in `libsignal/source.lock.json`. You can also open the PR first and take the hash from the Reproducibility workflow's artifacts. + +## 5. Scanner and blobs + +Run the F-Droid scanner over the pruned checkout (see FDROID_VALIDATION.md). If a new binary or blob appears, add it to `preparation.remove`. Never use `scanignore`. + +## 6. PR + +- Title: `build(deps): bump libsignal to X.Y.Z`. +- The four required checks must pass. The Reproducibility checks (`Build (replay, release checks)`, `Build (fdroid build)` and `compare`) prove both F-Droid builders agree. +- Mention the matching server or website libsignal version in the PR body. +- Do not change `version.properties` in this PR; releasing is a separate release-bump PR (skill `fdroid-release`). + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `verifyLibsignal` fails | `.so` hash differs from the pin | Expected after a bump: re-pin from the buildserver image (step 4) | +| Module fails: tag differs from catalogue | Catalogue and lock disagree | Make both `X.Y.Z` | +| `cargo` fails offline | `fetch` step did not run, or `Cargo.lock` changed | Run `build-libsignal.sh fetch` (or `all`) again | +| `compare` fails on `lib/arm64-v8a/libsignal_jni.so` | Toolchain, NDK or path remapping differs | Compare `PROVENANCE` and `SHA256SUMS` of both artifacts | +| `verify_release_apk.py` fails on natives | A new `.so` was packaged | Remove it or justify it; the allowed list is in the script | +| Website and app cannot talk | libsignal versions differ in a protocol-relevant way | Align versions with the server (cross-repo doc) | diff --git a/.claude/skills/store-screenshots/SKILL.md b/.claude/skills/store-screenshots/SKILL.md new file mode 100644 index 0000000..7049086 --- /dev/null +++ b/.claude/skills/store-screenshots/SKILL.md @@ -0,0 +1,102 @@ +--- +name: store-screenshots +description: Capture or refresh the F-Droid/store screenshots of Privee for Android (com.privee.app) using an Android emulator, a local Privee server and two app installs that chat with each other. Covers running the server from WSL, the debug build, a temporary second install, adb input and screencap on Windows, file naming under fastlane/metadata/android/en-US/images/phoneScreenshots, and cleanup. Use when asked to take, update or regenerate screenshots, store images or listing graphics, or to demo the app end to end on an emulator. +--- + +# Store screenshots + +F-Droid reads the listing from `fastlane/metadata/android/en-US/`. The phone screenshots are in `images/phoneScreenshots/` and are named `1.png` to `N.png`, in display order. The current set is: + +| File | Screen | +|---|---| +| `1.png` | Server choice | +| `2.png` | Welcome | +| `3.png` | Home (session list) | +| `4.png` | Conversation with a few messages | +| `5.png` | Safety number | + +Background reading: +- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md), on how the app finds and checks a server; +- the server's `local-dev-stack` skill (). + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/store-screenshots/SKILL.md`; +- `.github/skills/store-screenshots/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## 1. Start a current Privee server + +The app needs `GET /api/app/info`, so use an up-to-date Privee `main`, not an old checkout. + +```bash +# WSL, in a clone of https://github.com/MaxDac/Privee +export PATH=$HOME/.local/share/mise/shims:$PATH +mix setup # first time; needs Postgres (Windows service on localhost:5432 works) +PRIVEE_INSTANCE_NAME="Privee" PRIVEE_SOURCE_URL="https://github.com/MaxDac/Privee" mix phx.server +curl -s http://localhost:4000/api/app/info # expect "service":"privee","api_version":1 +``` + +The emulator reaches the host at `http://10.0.2.2:4000`. Debug builds accept `http://` and suggest that address. + +## 2. Start the emulator and install the debug app + +```powershell +emulator -list-avds +Start-Process emulator -ArgumentList '-avd','','-no-snapshot-save' +adb wait-for-device +./gradlew :app:installDebug # com.privee.app.debug +``` + +Use a clean, recent phone image (Pixel, light theme, English). Set a tidy status bar with demo mode: + +```bash +adb shell settings put global sysui_demo_allowed 1 +adb shell am broadcast -a com.android.systemui.demo -e command enter +adb shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1200 +adb shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false +adb shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 +adb shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false +``` + +## 3. Add a second install to chat with + +A conversation needs two sessions. Build a temporary second app id: + +1. In `app/build.gradle.kts`, change the debug `applicationIdSuffix = ".debug"` to `".debug2"`. +2. Run `./gradlew :app:installDebug`. +3. **Revert the change immediately** (`git checkout -- app/build.gradle.kts`) and check that `git status` is clean. + +Onboard both installs against `http://10.0.2.2:4000`, using "quick" sessions with generated names. Never use real names or a real server. Exchange a few friendly messages. + +- Messages are relayed live, so open the chat on both installs while sending. +- Switch apps with `adb shell monkey -p com.privee.app.debug 1` (or `.debug2`). +- `adb shell input text` needs spaces written as `%s`: `adb shell input text "Hi%sthere"`. +- Find coordinates with `adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml`. + +## 4. Capture + +On Windows, never pipe `adb exec-out screencap` through a PowerShell redirect, because it corrupts the PNG. Capture on the device and pull the file instead: + +```powershell +$dir = "fastlane/metadata/android/en-US/images/phoneScreenshots" +adb shell screencap -p /sdcard/shot.png; adb pull /sdcard/shot.png "$dir/1.png" +``` + +Then check each image: +- it is a valid PNG with portrait phone resolution; +- it shows no keyboard, unless the screen is about typing; +- no debug-only UI is visible; +- it contains no personal data. + +## 5. Clean up and commit + +```bash +adb uninstall com.privee.app.debug2 +adb shell am broadcast -a com.android.systemui.demo -e command exit +git status # only the PNGs should change; app/build.gradle.kts must be clean +``` + +Stop the emulator and the server. + +Screenshots usually ship in the next release-bump PR (`chore(release): X.Y.Z`; skill `fdroid-release`), because F-Droid picks up metadata from the release tag. They can also go in a separate `docs(store): refresh screenshots` PR. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..26a2fe1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,71 @@ +# Copilot instructions: Privee for Android + +This repository is the native Android client (`com.privee.app`, +AGPL-3.0-only) for [Privee](https://github.com/MaxDac/Privee), an anonymous +end-to-end encrypted chat. It is written in Kotlin with Jetpack Compose. It +talks to a Phoenix server (REST under `/api/app`, plus Phoenix channels) and +encrypts with libsignal. Releases go to GitHub and F-Droid as reproducible +builds signed by us. + +## Project map + +- `app/`: Compose UI (`ui/`), wiring and encrypted storage (`data/`), and UnifiedPush and notifications (`push/`). +- `core/net/`: the REST client (`PriveeApi`), the Phoenix channels client (`PhoenixSocket`) and the server check (`ServerInfo`). Plain Kotlin/JVM. +- `core/signal/`: the Signal protocol wrapper (`SignalClient`). Plain Kotlin/JVM. +- `libsignal/`: libsignal built from source for release and F-Droid builds (`source.lock.json` pins everything). +- `scripts/`: Python and bash release and F-Droid tooling, with tests in `scripts/tests/`. +- `metadata/com.privee.app.yml`: a mirror of the F-Droid recipe. +- `fastlane/metadata/android/`: the store listing. + +Docs to read before non-trivial changes: +- [docs/TECHNOLOGIES.md](../docs/TECHNOLOGIES.md): stack and versions. +- [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md): data flow, where to change what, and which changes affect reproducibility. +- [docs/RELEASING.md](../docs/RELEASING.md), [docs/FDROID.md](../docs/FDROID.md) and [docs/FDROID_VALIDATION.md](../docs/FDROID_VALIDATION.md). +- Server and contract: [Privee docs/cross-repo.md](https://github.com/MaxDac/Privee/blob/main/docs/cross-repo.md) and [client-api.md](https://github.com/MaxDac/Privee/blob/main/docs/client-api.md). + +## Skills + +Procedures live in `.github/skills//SKILL.md`. Load the matching one before starting: + +| Skill | When | +|---|---| +| `fdroid-release` | A release, the F-Droid submission or MR update, or an F-Droid reviewer reply | +| `libsignal-upgrade` | A libsignal, NDK or Rust toolchain bump, or a `.so` hash mismatch | +| `dependency-upgrade` | Any other dependency, plugin, SDK, Gradle or Actions bump, including Dependabot PRs | +| `store-screenshots` | Store screenshots, or an end-to-end run on an emulator against a local server | +| `cross-repo-change` | Changes that also need the server (MaxDac/Privee) | + +`.claude/skills/` holds byte-identical copies for Claude Code. When you edit a +skill, update both copies; `scripts/tests/test_skill_copies.py` fails otherwise. + +## Build and test + +- CI runs `./gradlew test :app:lintDebug :app:assembleDebug`, with JDK 17+, SDK `android-37.0` and build-tools `36.0.0`. +- Release tooling and the skill-copy check: `python3 -m unittest discover -s scripts/tests -v`. +- An F-Droid-identical release build: `bash scripts/fdroid-rb-docker.sh out` (Docker, on Linux or WSL). +- On Windows: + - run Gradle and `gh` from PowerShell; + - run the Python and bash tooling in WSL; + - run git from PowerShell, because WSL git cannot use a Windows worktree. + +## Coding rules + +- Modules under `core/` must not import `android.*` or `androidx.*`. Android-specific code goes in `app` behind an interface. +- Decode server JSON leniently (`ignoreUnknownKeys = true`) and ignore unknown channel events, so newer servers do not break older apps. +- Add tests with every change: JUnit 6, with MockWebServer for `core/net`. +- Put user-visible strings in `app/src/main/res/values/strings.xml`. +- Use Conventional Commits and fill in `.github/pull_request_template.md`. +- PRs need four checks: + - `Android (test, lint, assemble)`; + - `Build (replay, release checks)`; + - `Build (fdroid build)`; + - `compare`. + +## Never + +- Change, regenerate or commit the release signing key. F-Droid only accepts certificate `ea586e3f…92f9`. +- Edit `version.properties` or the changelogs outside a `chore(release): X.Y.Z` PR. +- Add Google Play Services, Firebase, analytics, trackers or prebuilt native libraries. Packaging a `.so` other than the two allowed by `scripts/verify_release_apk.py` counts too. +- Put message content, sender names or keys in logs, notifications, push payloads or backups. +- Introduce non-determinism into the APK: timestamps, absolute paths or random ordering. +- Change the app–server contract without following the server-first order in `cross-repo.md`. diff --git a/.github/skills/cross-repo-change/SKILL.md b/.github/skills/cross-repo-change/SKILL.md new file mode 100644 index 0000000..88ed740 --- /dev/null +++ b/.github/skills/cross-repo-change/SKILL.md @@ -0,0 +1,66 @@ +--- +name: cross-repo-change +description: Make a change that spans the Privee server (MaxDac/Privee) and the Android app (MaxDac/PriveeApp, com.privee.app). Covers the shared contract (REST under /api/app, Phoenix channels, api_version, push payload, libsignal version), the safe order of work (server first and backward compatible, deploy, then app release, then F-Droid), app-side implementation in core/net, core/signal and app, tests, and compatibility with older app versions still installed from F-Droid. Use when a feature, fix or refactor needs both repos, when the server API or channel events change, when bumping api_version, or when asked how the app and server fit together. +--- + +# Change both repos + +Privee is two repositories: +- **MaxDac/Privee**: the Phoenix server and the website, with a libsignal WASM client. +- **MaxDac/PriveeApp**: this Android app, with a libsignal Android client. + +The canonical contract and change order live on the server side: +- : the contract, version rules and release order; +- : endpoints and channel events; +- : the protocol. + +On the server, the matching skill is `api-change`. + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/cross-repo-change/SKILL.md`; +- `.github/skills/cross-repo-change/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## Why order matters + +Server deploys are immediate. App updates reach users days or weeks later, and F-Droid builds lag further, so old app versions stay in use for a long time. Therefore: + +1. **Server first, backward compatible.** + - Add new endpoints, events and fields. + - Keep old ones working. + - New request fields must be optional. + - Never change the meaning of an existing field. +2. **Deploy the server** (`main` → Fly.io, server skill `deploy`). +3. **App change.** Use the new contract, and degrade gracefully on servers that do not have it yet: self-hosted instances update on their own schedule. +4. **App release** (`fdroid-release` skill), then F-Droid picks it up. +5. **Remove the old server behaviour** only after no supported app version uses it. + +A breaking change needs a new `api_version`. The server keeps serving the old one until old apps are gone. The app adds the new version to `ServerInfo.SUPPORTED_API_VERSIONS` and keeps accepting the old one while it still supports such servers. + +## App-side checklist + +1. **Read the server PR and docs first.** Work in a separate clone of the server if you need to read code (`gh repo clone MaxDac/Privee`). +2. **REST:** + - edit `core/net/src/main/kotlin/com/privee/net/PriveeApi.kt`; + - add a MockWebServer test in `PriveeApiTest`; + - decode with `ignoreUnknownKeys`-style tolerance, so the server can add fields. +3. **Channels:** + - incoming events go in `PriveeSession.handle` (topic `session`) or in `ChatConversation` (topic `chat:`); + - calls go through `ServerCall` in `SignalClient`; + - unknown events must be ignored, not treated as errors. +4. **Protocol or key changes** go in `core/signal` and need tests in `SignalClientTest`. Both clients must agree, so check the website's behaviour and libsignal version (`libsignal-upgrade` skill). +5. **`api_version`:** update `ServerInfo.SUPPORTED_API_VERSIONS`, `ServerInfoTest`, and the "Choosing a server" section of the README. +6. **Push:** pushes are wake-ups only. Never add message content, sender names or keys to a push payload, on either side. +7. **Test against a local server.** Run the server's `main` (or the PR branch) and the debug app on an emulator at `http://10.0.2.2:4000`; see the `store-screenshots` skill, step 1. +8. **PR.** + - Use Conventional Commits, for example `feat(chat): …`, and link the server PR. + - The four required checks must pass. + - Do not touch `version.properties`. + +## Invariants (both repos) + +- The server only stores public key material, ciphertext and metadata it needs for routing. It never stores plaintext or private keys. +- Push payloads contain no content. +- Every API change stays compatible with app versions already released, or bumps `api_version`. +- Both repos are AGPL-3.0-only. The server exposes its source URL (`PRIVEE_SOURCE_URL`), and forks must keep doing so. diff --git a/.github/skills/dependency-upgrade/SKILL.md b/.github/skills/dependency-upgrade/SKILL.md new file mode 100644 index 0000000..8a95afd --- /dev/null +++ b/.github/skills/dependency-upgrade/SKILL.md @@ -0,0 +1,75 @@ +--- +name: dependency-upgrade +description: Upgrade Gradle, the Android Gradle Plugin, Kotlin, AndroidX/Compose, OkHttp, kotlinx, UnifiedPush or GitHub Actions in Privee for Android (com.privee.app) without breaking reproducible builds or F-Droid inclusion. Covers Dependabot PRs, the version catalogue, buildserver JDK/SDK/NDK constraints, the native-library allowlist, non-free dependency checks and the Reproducibility workflow. Use when asked to bump, update or upgrade a dependency, plugin, SDK level, Gradle wrapper or workflow action, or to review or merge a Dependabot PR. For libsignal use the libsignal-upgrade skill instead. +--- + +# Upgrade dependencies + +Every published APK must be byte-identical when built by us and by F-Droid, and +must contain no proprietary code. Any dependency change can break either. + +Background reading: +- [`docs/TECHNOLOGIES.md`](../../../docs/TECHNOLOGIES.md): the stack and why each part is there. +- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md): the section on changes that affect reproducible builds. +- [`docs/FDROID_VALIDATION.md`](../../../docs/FDROID_VALIDATION.md): local buildserver builds and diffoscope triage. + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/dependency-upgrade/SKILL.md`; +- `.github/skills/dependency-upgrade/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## Where versions live + +| What | File | +|---|---| +| Libraries and plugins | `gradle/libs.versions.toml` (single source of truth) | +| Gradle | `gradle/wrapper/gradle-wrapper.properties` (use `./gradlew wrapper --gradle-version X`; this also updates the wrapper jar) | +| SDK levels, JVM target | `app/build.gradle.kts`, `core/*/build.gradle.kts` | +| CI SDK packages and JDK | `.github/workflows/ci.yml` (`platforms;android-NN`, `build-tools;X`, Java 17) | +| Actions | `.github/workflows/*.yml`. `reproducibility.yml` and `release.yml` pin actions by SHA, so keep doing that. | +| Buildserver image | `scripts/fdroid-rb-docker.sh` (digest) | +| Release tooling | `PyYAML==6.0.3` in `ci.yml` | + +Dependabot (`.github/dependabot.yml`) opens weekly PRs for Gradle dependencies and Actions. + +## Procedure + +1. **One logical change per PR.** Change one library, or one coupled group (for example AGP plus Gradle, or Kotlin plus Compose compiler), in each PR. Title: `build(deps): bump from A to B`. +2. **Check that the license is free.** + - No Google Play Services, Firebase, GMS or proprietary SDKs. + - Check that new transitive dependencies are not proprietary: `./gradlew :app:dependencies --configuration releaseRuntimeClasspath`. +3. **Check the buildserver can build it.** + - F-Droid builds in `fdroidserver:buildserver-trixie` (Debian trixie, OpenJDK 21) with the SDK packages the recipe requests. + - AGP or Gradle bumps that need a newer JDK, or a `compileSdk` not yet in the buildserver, will fail there even if CI passes. + - If `compileSdk` or build-tools change, update `ci.yml` and the README requirements too. +4. **Build and test locally:** + ```bash + ./gradlew test :app:lintDebug :app:assembleDebug + python3 -m unittest discover -s scripts/tests # WSL on Windows + ``` +5. **Check the native libraries.** + - `scripts/verify_release_apk.py` allows exactly two `.so` files: `lib/arm64-v8a/libsignal_jni.so` and `lib/arm64-v8a/libandroidx.graphics.path.so`. + - If a bump adds a native library, either exclude it in `app/build.gradle.kts` (`packaging.jniLibs.excludes`) or stop and ask. A new prebuilt `.so` is usually not acceptable on F-Droid. +6. **Push the PR and wait for the four required checks:** + - `Android (test, lint, assemble)`; + - `Build (replay, release checks)`; + - `Build (fdroid build)`; + - `compare`. + + If `compare` fails, download the diffoscope artifact and use the triage table in FDROID_VALIDATION.md. +7. **Do not release in the same PR.** Version bumps go through the release-bump PR (skill `fdroid-release`). + +## Special cases + +| Upgrade | Watch out for | +|---|---| +| AGP | DSL changes in `app/build.gradle.kts`; `packaging` and `jniLibs.keepDebugSymbols` behaviour; dex differences between JDKs (the buildserver uses JDK 21) | +| Gradle wrapper | Commit `gradle-wrapper.jar` and the properties file together; `validateDistributionUrl` stays `true` | +| Kotlin | Compose compiler and serialization plugins share `kotlin` in the catalogue | +| Compose BOM | `material-icons-core` is pinned outside the BOM (1.7.8), so leave it there | +| UnifiedPush connector | Re-test push end to end with a distributor (ntfy); check `PriveePushService` API changes | +| OkHttp | `PhoenixSocket` WebSocket behaviour and `mockwebserver3` tests | +| desugar_jdk_libs | Required by libsignal-android; keep `isCoreLibraryDesugaringEnabled` | +| Buildserver image digest | Take it from fdroiddata's CI; rerun the Reproducibility workflow (see FDROID_VALIDATION.md) | +| libsignal | Use the `libsignal-upgrade` skill | diff --git a/.github/skills/fdroid-release/SKILL.md b/.github/skills/fdroid-release/SKILL.md index 4475e3a..8c9ec81 100644 --- a/.github/skills/fdroid-release/SKILL.md +++ b/.github/skills/fdroid-release/SKILL.md @@ -15,6 +15,8 @@ Background reading, if a step fails: - [`docs/RELEASING.md`](../../../docs/RELEASING.md): Release workflow, one-time setup, recovery. - [`docs/FDROID_VALIDATION.md`](../../../docs/FDROID_VALIDATION.md): local and buildserver builds, diffoscope. - [`libsignal/README.md`](../../../libsignal/README.md): source build of `libsignal_jni.so`, re-pinning. +- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) and [`docs/TECHNOLOGIES.md`](../../../docs/TECHNOLOGIES.md): what changes affect reproducibility, and the stack. +- Related skills: `libsignal-upgrade`, `dependency-upgrade`, `store-screenshots` (refresh store images before a release), `cross-repo-change` (server changes must be deployed before the app release that needs them). This file exists twice and the two copies must stay byte-identical: - `.claude/skills/fdroid-release/SKILL.md` (Claude Code); diff --git a/.github/skills/libsignal-upgrade/SKILL.md b/.github/skills/libsignal-upgrade/SKILL.md new file mode 100644 index 0000000..1f51bc7 --- /dev/null +++ b/.github/skills/libsignal-upgrade/SKILL.md @@ -0,0 +1,94 @@ +--- +name: libsignal-upgrade +description: Upgrade libsignal in Privee for Android (com.privee.app) while keeping release builds reproducible and F-Droid-compatible. Covers the version catalogue, libsignal/source.lock.json (tag, commit, Rust toolchain, NDK, Cargo cfg, prune list), rebuilding libsignal_jni.so from source, re-pinning its SHA-256 from the F-Droid buildserver image, the Reproducibility workflow, and keeping the Privee website's libsignal WASM on the same version. Use when asked to bump, update or upgrade libsignal, change the NDK or Rust toolchain for libsignal, or when verifyLibsignal or the Reproducibility check fails on libsignal_jni.so. +--- + +# Upgrade libsignal + +The app uses libsignal twice: +- in debug builds and CI, from Maven (`org.signal:libsignal-client` and `libsignal-android`); +- in release and F-Droid builds, built from source at a pinned commit (`-PlibsignalBuiltFromSource`). + +Both must use the same version. That version must also match the libsignal WASM build used by the Privee website, because the two clients talk to each other. + +Background reading: +- [`libsignal/README.md`](../../../libsignal/README.md): the source build and the bump checklist. +- [`docs/FDROID_VALIDATION.md`](../../../docs/FDROID_VALIDATION.md): buildserver builds and the diffoscope triage table. +- [`docs/TECHNOLOGIES.md`](../../../docs/TECHNOLOGIES.md): why libsignal is built from source. +- Server side: , the libsignal alignment section. + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/libsignal-upgrade/SKILL.md`; +- `.github/skills/libsignal-upgrade/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## 0. Decide the target version + +```bash +git ls-remote --tags https://github.com/signalapp/libsignal 'refs/tags/v*' | sort -t/ -k3 -V | tail -5 +grep -n libsignal gradle/libs.versions.toml +``` + +Check which libsignal version the Privee website uses (see the cross-repo doc). Ship an upgrade together with the server, or after it. If the protocol changed between versions, read libsignal's release notes and Privee's `docs/e2e-encryption.md` before going ahead. + +## 1. Bump the pins + +1. In `gradle/libs.versions.toml`, set `libsignal = "X.Y.Z"`. +2. In `libsignal/source.lock.json`: + - set `source.tag` to `vX.Y.Z`; + - set `source.commit` to the full SHA from `git ls-remote https://github.com/signalapp/libsignal refs/tags/vX.Y.Z`. Take the peeled `^{}` SHA if the tag is annotated. + - set `rust.toolchain` to the contents of libsignal's `rust-toolchain` file at that tag; + - check `build.ndkRevision`, `build.rustCfg` and `build.features` against libsignal's `java/build_jni.sh` and `.cargo/config.toml` at that tag; + - check that every `preparation.keep` path still exists at that tag. +3. If the NDK revision changes, also update `ndk:` in `metadata/com.privee.app.yml` and in the matching block of `docs/FDROID.md`. + +## 2. Build and test against Maven + +```bash +./gradlew test :app:lintDebug :app:assembleDebug +``` + +This build uses the Maven artifacts. Fix any API changes in `core/signal`, and in `app` where it uses libsignal directly. Then run `SignalClientTest`. + +## 3. Build from source locally (Linux or WSL) + +```bash +NDK_ROOT=/path/to/android-ndk-r28c bash libsignal/scripts/build-libsignal.sh all +./gradlew -PlibsignalBuiltFromSource -PallowUnpinnedLibsignal :app:assembleRelease +``` + +`-PallowUnpinnedLibsignal` is for this step only. Never commit or use it in CI. + +## 4. Re-pin the `.so` hash from the buildserver image + +The hash that matters is the one F-Droid gets, so build in the pinned buildserver image: + +```bash +bash scripts/fdroid-rb-docker.sh out +cat out/libsignal/SHA256SUMS +``` + +Write the `libsignal_jni.so` hash into `build.expectedSha256` in `libsignal/source.lock.json`. You can also open the PR first and take the hash from the Reproducibility workflow's artifacts. + +## 5. Scanner and blobs + +Run the F-Droid scanner over the pruned checkout (see FDROID_VALIDATION.md). If a new binary or blob appears, add it to `preparation.remove`. Never use `scanignore`. + +## 6. PR + +- Title: `build(deps): bump libsignal to X.Y.Z`. +- The four required checks must pass. The Reproducibility checks (`Build (replay, release checks)`, `Build (fdroid build)` and `compare`) prove both F-Droid builders agree. +- Mention the matching server or website libsignal version in the PR body. +- Do not change `version.properties` in this PR; releasing is a separate release-bump PR (skill `fdroid-release`). + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `verifyLibsignal` fails | `.so` hash differs from the pin | Expected after a bump: re-pin from the buildserver image (step 4) | +| Module fails: tag differs from catalogue | Catalogue and lock disagree | Make both `X.Y.Z` | +| `cargo` fails offline | `fetch` step did not run, or `Cargo.lock` changed | Run `build-libsignal.sh fetch` (or `all`) again | +| `compare` fails on `lib/arm64-v8a/libsignal_jni.so` | Toolchain, NDK or path remapping differs | Compare `PROVENANCE` and `SHA256SUMS` of both artifacts | +| `verify_release_apk.py` fails on natives | A new `.so` was packaged | Remove it or justify it; the allowed list is in the script | +| Website and app cannot talk | libsignal versions differ in a protocol-relevant way | Align versions with the server (cross-repo doc) | diff --git a/.github/skills/store-screenshots/SKILL.md b/.github/skills/store-screenshots/SKILL.md new file mode 100644 index 0000000..7049086 --- /dev/null +++ b/.github/skills/store-screenshots/SKILL.md @@ -0,0 +1,102 @@ +--- +name: store-screenshots +description: Capture or refresh the F-Droid/store screenshots of Privee for Android (com.privee.app) using an Android emulator, a local Privee server and two app installs that chat with each other. Covers running the server from WSL, the debug build, a temporary second install, adb input and screencap on Windows, file naming under fastlane/metadata/android/en-US/images/phoneScreenshots, and cleanup. Use when asked to take, update or regenerate screenshots, store images or listing graphics, or to demo the app end to end on an emulator. +--- + +# Store screenshots + +F-Droid reads the listing from `fastlane/metadata/android/en-US/`. The phone screenshots are in `images/phoneScreenshots/` and are named `1.png` to `N.png`, in display order. The current set is: + +| File | Screen | +|---|---| +| `1.png` | Server choice | +| `2.png` | Welcome | +| `3.png` | Home (session list) | +| `4.png` | Conversation with a few messages | +| `5.png` | Safety number | + +Background reading: +- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md), on how the app finds and checks a server; +- the server's `local-dev-stack` skill (). + +This file exists twice and the two copies must stay byte-identical: +- `.claude/skills/store-screenshots/SKILL.md`; +- `.github/skills/store-screenshots/SKILL.md`. + +`scripts/tests/test_skill_copies.py` enforces this. + +## 1. Start a current Privee server + +The app needs `GET /api/app/info`, so use an up-to-date Privee `main`, not an old checkout. + +```bash +# WSL, in a clone of https://github.com/MaxDac/Privee +export PATH=$HOME/.local/share/mise/shims:$PATH +mix setup # first time; needs Postgres (Windows service on localhost:5432 works) +PRIVEE_INSTANCE_NAME="Privee" PRIVEE_SOURCE_URL="https://github.com/MaxDac/Privee" mix phx.server +curl -s http://localhost:4000/api/app/info # expect "service":"privee","api_version":1 +``` + +The emulator reaches the host at `http://10.0.2.2:4000`. Debug builds accept `http://` and suggest that address. + +## 2. Start the emulator and install the debug app + +```powershell +emulator -list-avds +Start-Process emulator -ArgumentList '-avd','','-no-snapshot-save' +adb wait-for-device +./gradlew :app:installDebug # com.privee.app.debug +``` + +Use a clean, recent phone image (Pixel, light theme, English). Set a tidy status bar with demo mode: + +```bash +adb shell settings put global sysui_demo_allowed 1 +adb shell am broadcast -a com.android.systemui.demo -e command enter +adb shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1200 +adb shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false +adb shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 +adb shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false +``` + +## 3. Add a second install to chat with + +A conversation needs two sessions. Build a temporary second app id: + +1. In `app/build.gradle.kts`, change the debug `applicationIdSuffix = ".debug"` to `".debug2"`. +2. Run `./gradlew :app:installDebug`. +3. **Revert the change immediately** (`git checkout -- app/build.gradle.kts`) and check that `git status` is clean. + +Onboard both installs against `http://10.0.2.2:4000`, using "quick" sessions with generated names. Never use real names or a real server. Exchange a few friendly messages. + +- Messages are relayed live, so open the chat on both installs while sending. +- Switch apps with `adb shell monkey -p com.privee.app.debug 1` (or `.debug2`). +- `adb shell input text` needs spaces written as `%s`: `adb shell input text "Hi%sthere"`. +- Find coordinates with `adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml`. + +## 4. Capture + +On Windows, never pipe `adb exec-out screencap` through a PowerShell redirect, because it corrupts the PNG. Capture on the device and pull the file instead: + +```powershell +$dir = "fastlane/metadata/android/en-US/images/phoneScreenshots" +adb shell screencap -p /sdcard/shot.png; adb pull /sdcard/shot.png "$dir/1.png" +``` + +Then check each image: +- it is a valid PNG with portrait phone resolution; +- it shows no keyboard, unless the screen is about typing; +- no debug-only UI is visible; +- it contains no personal data. + +## 5. Clean up and commit + +```bash +adb uninstall com.privee.app.debug2 +adb shell am broadcast -a com.android.systemui.demo -e command exit +git status # only the PNGs should change; app/build.gradle.kts must be clean +``` + +Stop the emulator and the server. + +Screenshots usually ship in the next release-bump PR (`chore(release): X.Y.Z`; skill `fdroid-release`), because F-Droid picks up metadata from the release tag. They can also go in a separate `docs(store): refresh screenshots` PR. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fcf9131 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md: Privee for Android + +Native Android client (`com.privee.app`, AGPL-3.0-only) for +[Privee](https://github.com/MaxDac/Privee), an anonymous end-to-end encrypted +chat. Kotlin + Jetpack Compose. The app talks to a Phoenix server (REST +`/api/app` plus channels) and encrypts with libsignal. It is published on +GitHub Releases and F-Droid, using reproducible builds signed by us. + +## Read first + +| Topic | Doc | +|---|---| +| Stack, versions, notifications, native code | [docs/TECHNOLOGIES.md](docs/TECHNOLOGIES.md) | +| Modules, data flow, where to change what, reproducibility-sensitive changes | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | +| Releases and signing | [docs/RELEASING.md](docs/RELEASING.md) | +| F-Droid recipe, submission and MR loop | [docs/FDROID.md](docs/FDROID.md) | +| Local and buildserver builds, diffoscope | [docs/FDROID_VALIDATION.md](docs/FDROID_VALIDATION.md) | +| libsignal source build | [libsignal/README.md](libsignal/README.md) | +| App–server contract and change order | [Privee docs/cross-repo.md](https://github.com/MaxDac/Privee/blob/main/docs/cross-repo.md) | +| API, channels, protocol | [client-api.md](https://github.com/MaxDac/Privee/blob/main/docs/client-api.md), [e2e-encryption.md](https://github.com/MaxDac/Privee/blob/main/docs/e2e-encryption.md) | + +## Skills (`.claude/skills/`) + +| Skill | Use for | +|---|---| +| `fdroid-release` | Any release, the F-Droid submission or MR update, reviewer replies | +| `libsignal-upgrade` | Bumping libsignal or its NDK/Rust toolchain; `.so` hash pins | +| `dependency-upgrade` | Any other dependency, plugin, SDK, Gradle or Actions bump; Dependabot PRs | +| `store-screenshots` | Store screenshots; running the app end to end against a local server | +| `cross-repo-change` | Anything that also needs a server change | + +Every skill also exists, byte-identical, in `.github/skills/` for Copilot. Edit +one copy, copy it over the other, and run the skill-copy test. + +## Commands + +```bash +./gradlew test :app:lintDebug :app:assembleDebug # what CI runs (JDK 17+, SDK android-37.0, build-tools 36.0.0) +./gradlew :core:net:test :core:signal:test # fast JVM-only tests +python3 -m unittest discover -s scripts/tests -v # release tooling + skill-copy test +python3 scripts/release_version.py --check-version-properties +bash scripts/fdroid-rb-docker.sh out # F-Droid-identical release build (Docker, Linux/WSL) +``` + +On Windows: + +- Gradle runs from PowerShell. Set `ANDROID_HOME` if `local.properties` is missing. +- Run the Python tooling and the bash scripts in WSL. Windows `python` may be the Microsoft Store stub. +- `gh` is installed on Windows only, so call it from PowerShell. +- WSL git cannot use a Windows worktree's `.git`, so run git from PowerShell. +- For screenshots, use `adb shell screencap` followed by `adb pull`, not a PowerShell redirect. + +## Conventions + +- Conventional Commits (`feat:`, `fix:`, `build(deps):`, `docs:`, `chore(release):`). Fill in `.github/pull_request_template.md`. +- PRs to `main` need four checks: + - `Android (test, lint, assemble)`; + - `Build (replay, release checks)`; + - `Build (fdroid build)`; + - `compare`. + + Squash-merge. +- `core/*` are plain Kotlin/JVM modules: no `android.*` or `androidx.*` imports. +- Add or update tests alongside code: JUnit 6, with MockWebServer for `core/net`. +- Comment only what needs explaining. Keep docs in sync when behaviour changes. + +## Guardrails + +- **Signing:** never change, regenerate or commit the release key. F-Droid only accepts certificate `ea586e3f…92f9`. Never print secrets. +- **Versions:** change `version.properties` and `fastlane/.../changelogs/.txt` only in a release-bump PR (`chore(release): X.Y.Z`). Releases are manual, through `release.yml`. +- **Reproducibility:** no timestamps, absolute paths or non-determinism in the APK. Native libraries are limited to the two allowed by `scripts/verify_release_apk.py`. libsignal pins must stay consistent (`libs.versions.toml` and `libsignal/source.lock.json`). +- **F-Droid:** no Google Play Services, Firebase, analytics, trackers or prebuilt binaries. Never use `scanignore`. +- **Privacy:** no plaintext, keys or tokens in logs, notifications, push payloads or backups. State stays under `noBackupFilesDir`, encrypted. +- **Server compatibility:** respect `api_version` and the cross-repo change order (server first). +- **Upstream repos:** don't push to `fdroid/fdroiddata` directly. Use the fork `MaxDac/fdroiddata`, branch `com.privee.app`. diff --git a/README.md b/README.md index 1f75041..d3d7b69 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ The app uses Kotlin and Jetpack Compose. It talks to the same Phoenix server as | `core/net` | Privee REST API client and a minimal Phoenix channels client (OkHttp) | | `core/signal` | Signal protocol wrapper: key bundles, session setup, encrypt/decrypt, safety numbers | +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the data flow and where to make changes, and [docs/TECHNOLOGIES.md](docs/TECHNOLOGIES.md) for the full technology stack, including how notifications work. + ## Building Requirements: @@ -55,6 +57,18 @@ Privee is prepared for the official F-Droid repository; see [docs/FDROID.md](doc - Release builds compile libsignal's native library from source at a pinned commit instead of using the prebuilt one from Maven ([libsignal/README.md](libsignal/README.md)). Release APKs are therefore ARM64 only. - Builds are reproducible, so F-Droid publishes the same signed APK as the GitHub release, and you can switch between the two without reinstalling. - Store metadata lives in `fastlane/metadata/android`; the F-Droid recipe is mirrored in `metadata/com.privee.app.yml`. + +## Working on this repository (humans and AI agents) + +- [CLAUDE.md](CLAUDE.md) (Claude Code) and [.github/copilot-instructions.md](.github/copilot-instructions.md) (GitHub Copilot) summarise the commands, conventions and guardrails. +- Step-by-step procedures are skills, kept as identical copies in `.claude/skills/` and `.github/skills/`: + - `fdroid-release` + - `libsignal-upgrade` + - `dependency-upgrade` + - `store-screenshots` + - `cross-repo-change` +- Changes that also touch the server follow [Privee's cross-repo guide](https://github.com/MaxDac/Privee/blob/main/docs/cross-repo.md). + ## License Privee for Android is free software, licensed under the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9c19051 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,148 @@ +# Architecture + +How Privee for Android is put together, where to make common changes, and which +changes affect reproducible builds. For the stack and versions see +[TECHNOLOGIES.md](TECHNOLOGIES.md). For the server side and the shared +app-server contract see Privee's +[`docs/ARCHITECTURE.md`](https://github.com/MaxDac/Privee/blob/main/docs/ARCHITECTURE.md) +and [`docs/cross-repo.md`](https://github.com/MaxDac/Privee/blob/main/docs/cross-repo.md). + +## Modules + +```mermaid +flowchart LR + app[":app
Compose UI, storage,
push, wiring"] --> net[":core:net
REST + Phoenix channels"] + app --> signal[":core:signal
Signal protocol"] + signal --> libsignal["libsignal-client (Maven)
or :libsignal:android (source build)"] + app --> libsignal +``` + +| Module | Plugin | Contents | +|---|---|---| +| `app` | Android application | `MainActivity` (navigation), `PriveeApplication`, `ui/` screens and ViewModels, `data/` (wiring, storage, sessions, conversations), `push/` (UnifiedPush, notifications) | +| `core/net` | Kotlin JVM | `PriveeApi` (REST client for `/api/app`), `PhoenixSocket` (channels client), `ServerInfo` (server check and URL normalisation), `Http` | +| `core/signal` | Kotlin JVM | `SignalClient` (key publication, sessions, encrypt/decrypt, outbox, history, safety numbers), `PriveeProtocolStore`, `SignalState` | +| `libsignal/android` | Android library | Only with `-PlibsignalBuiltFromSource`: libsignal's Java sources plus the source-built `libsignal_jni.so` | + +**Rule:** `core/*` are plain Kotlin/JVM modules. They must not import +`android.*` or `androidx.*` (the PR template checks this). Anything that needs +Android goes in `app`, behind an interface such as `StateStorage`. This keeps +the protocol and network code testable with plain JUnit. + +## Runtime wiring + +`PriveeApplication` creates one `AppContainer`, a hand-written dependency +container: + +- **`ServerStore`**: the selected server (`server.bin`). +- **`ActiveServer`**: the selected server's `PriveeApi`, its per-server directory + `noBackupFilesDir/servers//`, and its `AccountStore`. +- **`PriveeSession`**: exists while signed in. It owns: + - the `PhoenixSocket`; + - the `session` channel; + - the `SignalClient`, whose state is stored in `signal-.bin` in the server directory. +- **`ChatConversation`**: one per open chat, using channel `chat:`. + +Everything stored is encrypted by `EncryptedFileStorage`, using AES-GCM under a non-exportable Android Keystore key. All of it lives under `noBackupFilesDir`, so it is never included in backups. + +## Data flow + +```mermaid +sequenceDiagram + participant U as User + participant A as App + participant S as Privee server + participant D as UnifiedPush distributor + U->>A: Enter server address + A->>S: GET /api/app/info + S-->>A: service "privee", api_version 1, name + U->>A: Register / log in + A->>S: POST /api/app/sessions (or sessions/log_in) + S-->>A: token + session + A->>S: WebSocket /app/socket/websocket (vsn 2.0.0, token in Sec-WebSocket-Protocol) + A->>S: join "session": signal_status, publish_identity / add_prekeys / rotate_signed_prekey + A->>D: UnifiedPush.register + D-->>A: endpoint + A->>S: PUT /api/app/push {endpoint} + U->>A: Open chat with peer + A->>S: join "chat:": request_peer_bundle, open_conversation, send_message + S-->>D: wake-up (no content) + D-->>A: onMessage → generic notification +``` + +1. **Choosing a server.** `ServerInfo.fetchServerInfo` calls + `GET /api/app/info`. It accepts the server only if `service` is + `privee` and `api_version` is in `SUPPORTED_API_VERSIONS` (currently `{1}`). + Release builds accept only `https://`. +2. **Authentication.** `PriveeApi` calls these endpoints, under `/api/app/`: + + | Method and path | Purpose | + |---|---| + | `POST sessions` | Register | + | `POST sessions/log_in` | Log in | + | `GET session` | Fetch the session | + | `DELETE session` | Log out | + | `PUT push` | Register the push endpoint | + | `DELETE push` | Remove the push endpoint | + + Authenticated requests carry the bearer token. +3. **Socket.** `PhoenixSocket` connects to `/app/socket/websocket?vsn=2.0.0`, sending the token as a `Sec-WebSocket-Protocol` entry, as phoenix.js does. It heartbeats, reconnects with backoff, and rejoins channels. +4. **Keys.** These run on the `session` channel. `SignalClient` reconciles keys with `signal_status` and publishes them through: + - `publish_identity` and `reset_identity`; + - `add_prekeys`; + - `rotate_signed_prekey`. + + It reacts to the server events `replenish_prekeys`, `identity_superseded` and `message_received`. +5. **Messages.** These run on the `chat:` channel: + - `request_peer_bundle` returns PQXDH bundles; + - `open_conversation` returns the epoch; + - `send_message` sends a message, with a client nonce and the epoch. + + The server event `new_message` or `peer_keys_ready` triggers `sync()`. Plaintext history stays on the device. +6. **Notifications:** see [TECHNOLOGIES.md#notifications](TECHNOLOGIES.md#notifications). + +The authoritative description of these endpoints and events is the server's +[`docs/client-api.md`](https://github.com/MaxDac/Privee/blob/main/docs/client-api.md). + +## Where to change what + +| Change | Where | Also update | +|---|---|---| +| New screen or navigation route | `app/.../ui/`, `MainActivity` (`NavHost`) | Strings in `app/src/main/res/values/strings.xml` | +| New REST call | `core/net/PriveeApi.kt` + `PriveeApiTest` | Server first (see [cross-repo](https://github.com/MaxDac/Privee/blob/main/docs/cross-repo.md)) | +| New channel event or call | `PriveeSession` / `ChatConversation` (handlers), `SignalClient` (calls) | Server `client-api.md` | +| Server API version support | `ServerInfo.SUPPORTED_API_VERSIONS` + `ServerInfoTest` | README "Choosing a server" | +| Protocol or key handling | `core/signal` + `SignalClientTest` | Server `e2e-encryption.md` (both clients must agree) | +| Local storage format | `data/*Store.kt`, `SignalState` | Migrate old data (see `AppContainer.migrateLegacyData`) | +| Notifications | `push/Notifications.kt`, `push/PriveePushService.kt` | | +| Dependency versions | `gradle/libs.versions.toml` | Skill `dependency-upgrade` | +| libsignal version | `libs.versions.toml` + `libsignal/source.lock.json` | Skill `libsignal-upgrade`; server WASM version | +| Store listing | `fastlane/metadata/android/en-US/` | Skill `store-screenshots` | +| Release | `version.properties` + `changelogs/.txt` (release-bump PR only) | Skill `fdroid-release` | + +## Changes that affect reproducible builds + +The Reproducibility workflow runs (and its required checks do real work) when a +PR touches any of: + +- `app/**`, `core/**`, `libsignal/**`, `gradle/**`; +- `*.gradle.kts`, `gradle.properties`, `version.properties`; +- `metadata/**`, `scripts/fdroid*`; +- `reproducibility.yml` or `release.yml`. + +Pay special attention to: + +- **Dependency or plugin upgrades.** They change `classes*.dex` and resources, and may add native libraries. `verify_release_apk.py` allows exactly two `.so` files. +- **Anything that embeds time, paths or randomness** in the APK, such as build timestamps, absolute paths or non-deterministic code generation. +- **The libsignal pins**, the NDK and the Rust toolchain. Changing any of them changes the `.so` hash, which must be re-pinned from a buildserver-image build. +- **The buildserver image digest** in `scripts/fdroid-rb-docker.sh`. + +If the Reproducibility check fails, follow +[FDROID_VALIDATION.md](FDROID_VALIDATION.md) (diffoscope triage table). + +## Invariants + +- Never change the release signing key or certificate. F-Droid publishes our APK only if it is signed with `AllowedAPKSigningKeys` (`ea586e3f…92f9`), and users cannot update across a key change. +- No Google Play Services, Firebase or other proprietary dependencies. +- No plaintext, keys or tokens in logs, notifications, push payloads or backups. +- Per-server isolation: one server's identity, keys or history are never reused on another. diff --git a/docs/TECHNOLOGIES.md b/docs/TECHNOLOGIES.md new file mode 100644 index 0000000..c74be15 --- /dev/null +++ b/docs/TECHNOLOGIES.md @@ -0,0 +1,109 @@ +# Technologies + +This page lists everything Privee for Android is built with and why. The single +source of truth for library versions is +[`gradle/libs.versions.toml`](../gradle/libs.versions.toml); the versions below +are a snapshot, so check the catalogue before relying on one. + +For the server (Elixir/Phoenix, the website and its WebAssembly libsignal) see +[Privee `docs/TECHNOLOGIES.md`](https://github.com/MaxDac/Privee/blob/main/docs/TECHNOLOGIES.md). +For how the pieces fit together see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Stack at a glance + +| Area | Technology | Version | Notes | +|---|---|---|---| +| Language | Kotlin | 2.4.20 | JVM target 17, also used for the Compose compiler and serialization plugins | +| Build | Gradle (wrapper) | 9.6.1 | `gradle/wrapper/gradle-wrapper.properties` | +| Build | Android Gradle Plugin | 9.4.1 | `compileSdk` 37, `targetSdk` 36, `minSdk` 26 | +| Build | Core library desugaring | `desugar_jdk_libs` 2.1.5 | Required by libsignal-android on older API levels | +| UI | Jetpack Compose (BOM) | 2026.09.00 | Material 3; `material-icons-core` frozen at 1.7.8 | +| UI | Activity Compose | 1.11.0 | Single activity (`MainActivity`) | +| UI | Navigation Compose | 2.10.2 | Routes `welcome`, `register`, `login`, `home`, `chat/{name}` | +| UI | AndroidX Lifecycle | 2.9.3 | ViewModels, `ProcessLifecycleOwner` for foreground detection | +| Background | WorkManager | 2.12.0 | In the catalogue only; no module depends on it yet | +| Networking | OkHttp | 5.5.0 | REST and WebSocket; MockWebServer in tests | +| Networking | Phoenix channels client | in-house (`core/net/PhoenixSocket.kt`) | Serializer v2 over OkHttp WebSocket, heartbeats, reconnect, rejoin | +| Serialization | kotlinx.serialization JSON | 1.11.0 | | +| Concurrency | kotlinx.coroutines | 1.11.0 | `Flow`/`StateFlow` for UI state and channel events | +| Crypto | libsignal (`libsignal-client`, `libsignal-android`) | 0.86.5 | PQXDH + Double Ratchet; must match the website's libsignal WASM version | +| Local storage | Android Keystore + AES-GCM | platform | `EncryptedFileStorage`: one encrypted file per store, in `noBackupFilesDir` | +| Push | UnifiedPush connector | 3.3.5 | No Google Play Services / FCM | +| Tests | JUnit Jupiter (JUnit 6) | 6.1.3 | `./gradlew test`; `core/*` tests run on the plain JVM | +| Release tooling | Python 3 + PyYAML 6.0.3 | | `scripts/*.py`, tested by `scripts/tests` | +| Release tooling | F-Droid buildserver image | `buildserver-trixie` (pinned digest) | `scripts/fdroid-rb-docker.sh`, the image fdroiddata CI uses | +| Native build | Rust nightly + Android NDK r28c | `nightly-2025-09-24`, NDK `28.2.13676358` | Only for release builds; see below | +| CI | GitHub Actions | | `ci.yml`, `reproducibility.yml`, `release.yml` | + +## Notifications + +Privee uses [UnifiedPush](https://unifiedpush.org), not Firebase Cloud +Messaging, so the app has no Google dependency and can ship on F-Droid. + +- The user must install a UnifiedPush **distributor** (for example ntfy, or + NextPush with Nextcloud). Without one the app works, but only notifies while + it is open. +- `PushRegistration` registers with the distributor after sign-in. + `PriveePushService.onNewEndpoint` sends the endpoint URL to the server + (`PUT /api/app/push`); sign-out deletes it (`DELETE /api/app/push`). +- The server's push is a **wake-up signal only**: it carries no message + content and no sender. On a push, `PriveePushService.onMessage` shows a + generic "new message" notification if the app is in the background. Message + content is always fetched over the authenticated Phoenix socket and decrypted + on the device. +- While the app is open, the `session` channel's `message_received` event + drives notifications instead. + +## Encryption + +- The Signal protocol via the official libsignal Java/Kotlin API, wrapped in + `core/signal` (`SignalClient`, `PriveeProtocolStore`, `SignalState`). +- Keys, sessions and history live on the device only, encrypted at rest with a + non-exportable Android Keystore AES-GCM key, and stored per server and per + account. The server only sees public key material and ciphertext. +- Interoperability with the website depends on both using the same libsignal + version. The protocol is documented in Privee's + [`docs/e2e-encryption.md`](https://github.com/MaxDac/Privee/blob/main/docs/e2e-encryption.md). + +## Native code: libsignal built from source + +The Maven `libsignal-android` AAR ships a prebuilt `libsignal_jni.so`, which +F-Droid does not accept. With `-PlibsignalBuiltFromSource` (set by the F-Droid +recipe and the Release workflow), the build swaps in the `:libsignal:android` +module, built from source at a pinned commit by +`libsignal/scripts/build-libsignal.sh`. + +- Pins live in [`libsignal/source.lock.json`](../libsignal/source.lock.json): + tag and commit, rustup and its checksum, the Rust toolchain, the NDK + revision, Cargo features, and the expected SHA-256 of the `.so`. +- Only `arm64-v8a` is built, so **release APKs are ARM64 only**. Debug builds + and CI use the Maven artifacts. +- Details: [`libsignal/README.md`](../libsignal/README.md). + +## Release and reproducibility + +- Releases are manual (`release.yml`, `workflow_dispatch`) and version numbers + come from `version.properties`. +- Builds are reproducible: the Reproducibility workflow builds each relevant + PR twice (a replay in the buildserver image and a real `fdroid build`) and + requires byte-identical APKs. +- F-Droid rebuilds each release, and since the result is identical it + publishes **our** signed APK (`Binaries` + `AllowedAPKSigningKeys` in the + recipe). Users can move between the GitHub and F-Droid APKs without + reinstalling. +- `scripts/verify_release_apk.py` rejects an APK whose native libraries are not + exactly `libsignal_jni.so` and `libandroidx.graphics.path.so`. + +See [RELEASING.md](RELEASING.md), [FDROID.md](FDROID.md) and +[FDROID_VALIDATION.md](FDROID_VALIDATION.md). + +## Deliberately not used + +| Not used | Why | +|---|---| +| Google Play Services, Firebase, FCM | F-Droid inclusion and privacy; UnifiedPush instead | +| Analytics, crash reporting, ads | Privacy; F-Droid anti-features | +| Prebuilt native libraries | F-Droid policy; libsignal is built from source | +| Room / SQLite | State is small; Keystore-encrypted files are enough | +| Dependency injection frameworks | A hand-written `AppContainer` is enough | +| phoenix.js / a third-party Phoenix client | A small in-house client keeps `core/net` pure Kotlin and dependency-light | diff --git a/scripts/tests/test_skill_copies.py b/scripts/tests/test_skill_copies.py index 8546447..7334c2f 100644 --- a/scripts/tests/test_skill_copies.py +++ b/scripts/tests/test_skill_copies.py @@ -1,26 +1,53 @@ -"""The fdroid-release skill is shipped for Claude Code and GitHub Copilot; both copies must match.""" +"""Every skill is shipped for Claude Code and GitHub Copilot; both copies must match.""" from pathlib import Path +import re import unittest ROOT = Path(__file__).resolve().parents[2] -COPIES = [ - ROOT / ".claude/skills/fdroid-release/SKILL.md", - ROOT / ".github/skills/fdroid-release/SKILL.md", -] +CLAUDE = ROOT / ".claude/skills" +COPILOT = ROOT / ".github/skills" + + +def skill_files(base: Path) -> set[str]: + return {path.relative_to(base).as_posix() for path in base.rglob("*") if path.is_file()} class SkillCopiesTests(unittest.TestCase): - def test_copies_are_identical(self): - claude, copilot = (path.read_bytes().replace(b"\r\n", b"\n") for path in COPIES) + def test_same_skills_in_both_directories(self): + self.assertTrue(skill_files(CLAUDE), "no skills found") self.assertEqual( - claude, copilot, - "Edit .claude/skills/fdroid-release/SKILL.md and copy it to .github/skills/fdroid-release/SKILL.md", + skill_files(CLAUDE), skill_files(COPILOT), + "Every file under .claude/skills must exist under .github/skills and vice versa", ) + def test_copies_are_identical(self): + for relative in sorted(skill_files(CLAUDE) & skill_files(COPILOT)): + with self.subTest(file=relative): + claude, copilot = ( + (base / relative).read_bytes().replace(b"\r\n", b"\n") for base in (CLAUDE, COPILOT) + ) + self.assertEqual( + claude, copilot, + f"Edit .claude/skills/{relative} and copy it to .github/skills/{relative}", + ) + def test_front_matter_names_the_skill(self): - text = COPIES[0].read_text(encoding="utf-8") - self.assertTrue(text.startswith("---\nname: fdroid-release\ndescription: ")) + for skill in sorted(path for path in CLAUDE.iterdir() if path.is_dir()): + with self.subTest(skill=skill.name): + text = (skill / "SKILL.md").read_text(encoding="utf-8").replace("\r\n", "\n") + self.assertRegex(skill.name, r"^[a-z0-9]+(-[a-z0-9]+)*$") + match = re.match(r"---\nname: (\S+)\ndescription: (.+)\n---\n", text) + self.assertIsNotNone(match, "SKILL.md must start with name/description front matter") + self.assertEqual(match.group(1), skill.name) + self.assertGreater(len(match.group(2)), 80, "description should say when to use the skill") + + def test_relative_links_resolve(self): + for skill_md in sorted(CLAUDE.rglob("SKILL.md")): + text = skill_md.read_text(encoding="utf-8") + for target in re.findall(r"\]\((\.\.?/[^)#]+)", text): + with self.subTest(skill=skill_md.parent.name, link=target): + self.assertTrue((skill_md.parent / target).resolve().exists()) if __name__ == "__main__": From 374e48489401b3347ec7e75f1f123ab212f83d00 Mon Sep 17 00:00:00 2001 From: Massimiliano D'Acunzo Date: Thu, 8 Oct 2026 11:57:17 +0100 Subject: [PATCH 3/3] docs: align cross-repo changes with manual server deployment Document PriveeDeploy ownership, CI and confirmation gates, and staged API-version migration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ea5781b-c84c-47b9-8f99-781002da3818 --- .claude/skills/cross-repo-change/SKILL.md | 28 +++++++++++++++++++---- .github/skills/cross-repo-change/SKILL.md | 28 +++++++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.claude/skills/cross-repo-change/SKILL.md b/.claude/skills/cross-repo-change/SKILL.md index 88ed740..cdfd709 100644 --- a/.claude/skills/cross-repo-change/SKILL.md +++ b/.claude/skills/cross-repo-change/SKILL.md @@ -9,6 +9,9 @@ Privee is two repositories: - **MaxDac/Privee**: the Phoenix server and the website, with a libsignal WASM client. - **MaxDac/PriveeApp**: this Android app, with a libsignal Android client. +Deployment is owned separately by [MaxDac/PriveeDeploy](https://github.com/MaxDac/PriveeDeploy). +Privee runs CI only; merging a server PR does not deploy it. + The canonical contract and change order live on the server side: - : the contract, version rules and release order; - : endpoints and channel events; @@ -24,19 +27,36 @@ This file exists twice and the two copies must stay byte-identical: ## Why order matters -Server deploys are immediate. App updates reach users days or weeks later, and F-Droid builds lag further, so old app versions stay in use for a long time. Therefore: +Server deployment is manual and can reach users before an app update. App updates reach users days or weeks later, and F-Droid builds lag further, so old app versions stay in use for a long time. Therefore: 1. **Server first, backward compatible.** - Add new endpoints, events and fields. - Keep old ones working. - New request fields must be optional. - Never change the meaning of an existing field. -2. **Deploy the server** (`main` → Fly.io, server skill `deploy`). +2. **Deploy the server separately.** Follow the server's + [`deploy-privee` skill](https://github.com/MaxDac/Privee/blob/main/.github/skills/deploy-privee/SKILL.md). + Check successful server CI for the full source commit SHA, obtain explicit + production-deploy confirmation, then trigger PriveeDeploy's manual workflow: + + ```bash + gh workflow run deploy.yml -R MaxDac/PriveeDeploy -f ref= + ``` + + For forks, use the owner's deploy repository and verify its `PRIVEE_REPO` + setting matches the server repository. Wait for deployment to succeed and + verify `GET /api/app/info` before releasing an app that needs it. 3. **App change.** Use the new contract, and degrade gracefully on servers that do not have it yet: self-hosted instances update on their own schedule. 4. **App release** (`fdroid-release` skill), then F-Droid picks it up. 5. **Remove the old server behaviour** only after no supported app version uses it. -A breaking change needs a new `api_version`. The server keeps serving the old one until old apps are gone. The app adds the new version to `ServerInfo.SUPPORTED_API_VERSIONS` and keeps accepting the old one while it still supports such servers. +A breaking change needs an explicit migration plan, not just a new +`api_version`. The discovery response reports one integer and released apps +currently accept only `1`: changing it immediately locks those apps out. +First ship an app that accepts both versions and works with the old server. +Keep existing server behaviour working while users update, including F-Droid +users. Only then deploy the API-version change, with an agreed compatibility +window. Additive, backward-compatible changes keep API version `1`. ## App-side checklist @@ -62,5 +82,5 @@ A breaking change needs a new `api_version`. The server keeps serving the old on - The server only stores public key material, ciphertext and metadata it needs for routing. It never stores plaintext or private keys. - Push payloads contain no content. -- Every API change stays compatible with app versions already released, or bumps `api_version`. +- Every API change stays compatible with released app versions; an API-version bump requires the staged migration above. - Both repos are AGPL-3.0-only. The server exposes its source URL (`PRIVEE_SOURCE_URL`), and forks must keep doing so. diff --git a/.github/skills/cross-repo-change/SKILL.md b/.github/skills/cross-repo-change/SKILL.md index 88ed740..cdfd709 100644 --- a/.github/skills/cross-repo-change/SKILL.md +++ b/.github/skills/cross-repo-change/SKILL.md @@ -9,6 +9,9 @@ Privee is two repositories: - **MaxDac/Privee**: the Phoenix server and the website, with a libsignal WASM client. - **MaxDac/PriveeApp**: this Android app, with a libsignal Android client. +Deployment is owned separately by [MaxDac/PriveeDeploy](https://github.com/MaxDac/PriveeDeploy). +Privee runs CI only; merging a server PR does not deploy it. + The canonical contract and change order live on the server side: - : the contract, version rules and release order; - : endpoints and channel events; @@ -24,19 +27,36 @@ This file exists twice and the two copies must stay byte-identical: ## Why order matters -Server deploys are immediate. App updates reach users days or weeks later, and F-Droid builds lag further, so old app versions stay in use for a long time. Therefore: +Server deployment is manual and can reach users before an app update. App updates reach users days or weeks later, and F-Droid builds lag further, so old app versions stay in use for a long time. Therefore: 1. **Server first, backward compatible.** - Add new endpoints, events and fields. - Keep old ones working. - New request fields must be optional. - Never change the meaning of an existing field. -2. **Deploy the server** (`main` → Fly.io, server skill `deploy`). +2. **Deploy the server separately.** Follow the server's + [`deploy-privee` skill](https://github.com/MaxDac/Privee/blob/main/.github/skills/deploy-privee/SKILL.md). + Check successful server CI for the full source commit SHA, obtain explicit + production-deploy confirmation, then trigger PriveeDeploy's manual workflow: + + ```bash + gh workflow run deploy.yml -R MaxDac/PriveeDeploy -f ref= + ``` + + For forks, use the owner's deploy repository and verify its `PRIVEE_REPO` + setting matches the server repository. Wait for deployment to succeed and + verify `GET /api/app/info` before releasing an app that needs it. 3. **App change.** Use the new contract, and degrade gracefully on servers that do not have it yet: self-hosted instances update on their own schedule. 4. **App release** (`fdroid-release` skill), then F-Droid picks it up. 5. **Remove the old server behaviour** only after no supported app version uses it. -A breaking change needs a new `api_version`. The server keeps serving the old one until old apps are gone. The app adds the new version to `ServerInfo.SUPPORTED_API_VERSIONS` and keeps accepting the old one while it still supports such servers. +A breaking change needs an explicit migration plan, not just a new +`api_version`. The discovery response reports one integer and released apps +currently accept only `1`: changing it immediately locks those apps out. +First ship an app that accepts both versions and works with the old server. +Keep existing server behaviour working while users update, including F-Droid +users. Only then deploy the API-version change, with an agreed compatibility +window. Additive, backward-compatible changes keep API version `1`. ## App-side checklist @@ -62,5 +82,5 @@ A breaking change needs a new `api_version`. The server keeps serving the old on - The server only stores public key material, ciphertext and metadata it needs for routing. It never stores plaintext or private keys. - Push payloads contain no content. -- Every API change stays compatible with app versions already released, or bumps `api_version`. +- Every API change stays compatible with released app versions; an API-version bump requires the staged migration above. - Both repos are AGPL-3.0-only. The server exposes its source URL (`PRIVEE_SOURCE_URL`), and forks must keep doing so.