Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 5fc26057d7 test: cover _resolveDatabasePath retry logic to catch budget regressions (#167)
Issue #166 exposed that the retry budget in _resolveDatabasePath() was too
small for slow Android devices, but no unit test exercised that code path.

Expose two testing helpers (resolveDatabasePathForTesting /
resetDatabasePathForTesting) and add two new tests using fake_async:

- verifies the retry loop eventually succeeds after transient failures
  (a _SucceedAfterNPathProvider that fails N times then returns a path)
- verifies the function throws PlatformException with the right message
  after exhausting all retries

Both tests advance fake time instead of waiting real-world milliseconds,
so they run in < 1 ms each.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:29:30 +02:00
47 changed files with 314 additions and 2037 deletions
-1
View File
@@ -10,7 +10,6 @@ FROM ghcr.io/catthehacker/ubuntu:go-24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
stunnel4 \
netcat-openbsd \
age \
&& rm -rf /var/lib/apt/lists/*
# Dagger CLI — pinned to match the engine version on the runner host
-71
View File
@@ -3,41 +3,7 @@ name: CI
on:
push:
branches: [main]
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
pull_request:
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
jobs:
check:
@@ -64,48 +30,11 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Locate Docker daemon for local Dagger engine
run: |
# Skip if remote Dagger engine is already configured (preferred path)
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
echo "Remote Dagger engine configured, no local Docker needed."
exit 0
fi
# Try host Docker socket (DooD) if runner mounts it
if [ -S /var/run/docker.sock ]; then
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
echo "Docker available via host socket."
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
echo "CI will likely fail at the Dagger step." >&2
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger
- name: Prune Dagger cache after check
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+39 -134
View File
@@ -6,66 +6,20 @@ on:
workflow_dispatch:
jobs:
check-changes:
name: Detect Changed Files
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
android: ${{ steps.diff.outputs.android }}
linux: ${{ steps.diff.outputs.linux }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect Android and Linux changes
id: diff
shell: bash
run: |
# On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
echo "Changed files:"
echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|| echo "android=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED" | grep -qE "$linux_re" \
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT"
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -76,15 +30,10 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Run Android Tests on Firebase Test Lab
if: env.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != ''
env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
@@ -96,19 +45,16 @@ jobs:
name: Build & Deploy to Play Store
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -119,58 +65,26 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Publish Android to Play Store
if: env.PLAY_STORE_CONFIG_JSON != ''
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1"
run: task publish-android
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk:
name: Build & Deploy APK to Server
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Build & Deploy APK to server
if: env.SSH_PRIVATE_KEY != ''
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but we don't want that to fail the whole job — the Play
# Store publish above already succeeded. The overall job stays green even
# though this step shows as failed/orange in the UI.
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1"
run: task deploy-apk
@@ -182,19 +96,16 @@ jobs:
name: Build Linux Release
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.linux == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -205,15 +116,17 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Build & Deploy Linux to server
if: env.SSH_PRIVATE_KEY != ''
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but the build step that precedes this (done via Dagger)
# already succeeded. Deployment is best-effort; a missing secret should not
# turn the job red. The step will show as failed/orange in the UI even though
# the overall job is green — this is intentional.
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task deploy-linux
@@ -224,22 +137,21 @@ jobs:
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
needs: [build-linux, deploy-playstore]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -250,15 +162,14 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Generate build history and deploy website
if: env.SSH_PRIVATE_KEY != ''
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
# should not block the overall workflow status.
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
@@ -269,14 +180,8 @@ jobs:
label-deploy-health:
name: Update Deploy Health Label
runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
)
needs: [test-android-firebase, deploy-playstore, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5
steps:
@@ -285,7 +190,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
+3
View File
@@ -11,6 +11,7 @@ jobs:
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
if: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -31,6 +32,7 @@ jobs:
- name: Set up SSH key
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
@@ -40,6 +42,7 @@ jobs:
- name: Deploy Windows to server
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
+6 -7
View File
@@ -202,8 +202,6 @@ jobs:
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Build Linux release
run: |
@@ -217,20 +215,20 @@ jobs:
REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \
"cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
- name: Generate build history pages
@@ -246,5 +244,6 @@ jobs:
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
+1 -3
View File
@@ -22,15 +22,13 @@ assets/changelog.txt
.env.local
.envrc
.direnv/
secrets.env # plaintext secrets — encrypted version (secrets.age) is committed
# --- Android ---
android/.gradle/
android/local.properties
android/app/google-services.json
android/key.properties
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
android/app/src/main/java/io/flutter/plugins/
.android/
Android/
.gradle/
+3 -63
View File
@@ -174,70 +174,10 @@ Run a secret manager co-located with the Dagger host. The CI job authenticates w
- Vault itself becomes a security-critical single point of failure.
- Operational overhead likely disproportionate for a small single-developer project.
### Option 5: Encrypted secrets file (age) — **implemented**
Store all production secrets in a file (`secrets.env`) that is encrypted with
[age](https://age-encryption.org/) into `secrets.age`. The encrypted file is
committed to the repository. Only the age private key — a single string — is
stored in Codeberg as `SECRETS_AGE_KEY`. Any CI job or developer with the key
can decrypt the file and obtain all secrets.
**How it works:**
1. Generate a key pair once:
```bash
age-keygen -o ~/.config/age/sharedinbox.key
age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key
```
2. Copy `secrets.env.example` to `secrets.env`, fill in all values, then encrypt:
```bash
scripts/secrets-encrypt.sh # reads public key from .age-public-key
git add secrets.age && git commit -m "chore: update encrypted secrets"
```
3. Add the private key content as `SECRETS_AGE_KEY` in Codeberg repository secrets.
4. CI jobs call `scripts/secrets-decrypt.sh` (with `SECRETS_AGE_KEY` set) before
any step that needs production credentials. The script writes each variable
to `$GITHUB_ENV` so subsequent steps see them automatically.
**Keeping local and CI in sync:**
When you rotate a secret locally, update `secrets.env`, re-run
`scripts/secrets-encrypt.sh`, and commit the new `secrets.age`. CI will pick
up the fresh secrets on the next push — no manual CI variable updates needed.
Multi-line values (SSH keys, certificates) must be stored as a single line
with `\n` escape sequences inside double quotes. Example:
```
SSH_PRIVATE_KEY="<header>\n<base64 key body>\n<footer>"
```
**Pro:**
- One secret (`SECRETS_AGE_KEY`) in Codeberg instead of many.
- Encrypted secrets are version-controlled — rotating a secret is a git commit.
- Local dev environment and CI always use the same encrypted source of truth.
- `age` is a simple, audited tool with no server infrastructure.
- The private key never appears in workflow files or logs.
**Con:**
- `secrets.age` exposes the list of variable *names* (visible in the encrypted
file if the format leaks, though not the values).
- All credentials share a single key — compromising `SECRETS_AGE_KEY` exposes
everything at once.
- Key rotation requires re-encrypting `secrets.age` and updating the CI secret.
### Recommendation
**Option 5** (encrypted secrets file) is now the active approach. It reduces
Codeberg secrets to exactly two categories:
- **Dagger access credentials** — `DAGGER_STUNNEL_URL`, `DAGGER_CA_CERT`,
`DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`.
- **Master key** — `SECRETS_AGE_KEY`.
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
**Option 1** (runner-level env vars) or **Option 2** (secret files) remain
valid if you prefer not to commit an encrypted file to the repository.
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
**Option 3** (Dagger host as orchestrator) is worth considering once the
trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest
security boundary at the cost of reduced CI observability.
**Option 4** (Vault) becomes worthwhile if the project grows to multiple
runners or team members who each need audited access to deploy credentials.
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
+18 -59
View File
@@ -215,10 +215,8 @@ tasks:
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
@@ -253,24 +251,17 @@ tasks:
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
msg: "ANDROID_KEYSTORE_BASE64 is not set"
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website:
desc: Build and publish website via Dagger
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -293,13 +284,8 @@ tasks:
for attempt in 1 2 3; do
run_dagger "$@" && return 0
RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else
return "$RC"
fi
@@ -329,12 +315,6 @@ tasks:
wait "$RECV_PID" 2>/dev/null || true
exit $RC
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup]
@@ -382,29 +362,25 @@ tasks:
msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
# Merge with any existing latest.json so we don't overwrite the windows key
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
echo "Uploaded $TARBALL and updated latest.json"
@@ -429,28 +405,24 @@ tasks:
msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
if [ -n "$LINUX_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
echo "Uploaded $ZIPFILE and updated latest.json"
@@ -600,18 +572,14 @@ tasks:
msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
APK_NAME="sharedinbox-mua-$HASH.apk"
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no \
build/app/outputs/flutter-apk/app-release.apk \
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
echo "Uploaded $APK_NAME to $REMOTE_DIR"
@@ -640,27 +608,18 @@ tasks:
website-deploy:
desc: Deploy the website via rsync to public_html
deps: [website-build]
preconditions:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \
${SSH_USER}@${SSH_HOST}:public_html/
check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks, check-secrets]
check-secrets:
desc: Test secrets encrypt/decrypt scripts (requires age)
cmds:
- bash scripts/test_secrets.sh
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
+1
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
@@ -1,84 +0,0 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
+22 -58
View File
@@ -183,7 +183,7 @@ func (m *Ci) toolchain() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld", "age"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
WithExec([]string{"/bin/sh", "-c",
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
@@ -195,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
WithUser("ci").
WithExec([]string{"/bin/sh", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
}
// Base is the Flutter toolchain container with mutable cache mounts attached.
@@ -319,13 +318,12 @@ func (m *Ci) Hugo() *dagger.Container {
}
// Deploy container for rsync/ssh
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
}
// Stalwart mail server service for backend and integration tests.
@@ -381,21 +379,6 @@ func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
Stdout(ctx)
}
// CheckSecrets verifies the secrets encrypt/decrypt scripts work correctly.
func (m *Ci) CheckSecrets(ctx context.Context) (string, error) {
scriptSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"scripts/secrets-encrypt.sh", "scripts/secrets-decrypt.sh", "scripts/test_secrets.sh"},
})
return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "age"}).
WithDirectory("/src", scriptSrc).
WithWorkdir("/src").
WithExec([]string{"bash", "scripts/test_secrets.sh"}).
Stdout(ctx)
}
// CheckLayers enforces that ui/ does not import data/.
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
return m.Base().
@@ -486,9 +469,6 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
if _, err := m.CheckLayers(ctx); err != nil {
return "Layer check failed", err
}
if _, err := m.CheckSecrets(ctx); err != nil {
return "Secrets script check failed", err
}
checkSetup := m.setup(m.checkSrc())
@@ -534,7 +514,6 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
func (m *Ci) GenerateBuildHistory(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
) *dagger.Directory {
@@ -546,7 +525,7 @@ func (m *Ci) GenerateBuildHistory(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithExec([]string{"chmod", "700", "/root/.ssh"}).
WithEnvVariable("SSH_USER", sshUser).
WithEnvVariable("SSH_HOST", sshHost).
WithDirectory("/src", scriptSource).
@@ -559,11 +538,10 @@ func (m *Ci) GenerateBuildHistory(
func (m *Ci) BuildWebsite(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"website/"},
@@ -580,13 +558,12 @@ func (m *Ci) BuildWebsite(
func (m *Ci) PublishWebsite(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
) (string, error) {
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithDirectory("/public", public).
WithExec([]string{"rsync", "-avz", "--delete",
"--exclude=*.apk", "--exclude=*.tar.gz",
@@ -612,7 +589,6 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
func (m *Ci) DeployLinux(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
commitHash string,
@@ -623,11 +599,11 @@ func (m *Ci) DeployLinux(
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithDirectory("/bundle", bundle).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
Stdout(ctx)
}
@@ -650,7 +626,6 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
func (m *Ci) DeployApk(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
commitHash string,
@@ -664,10 +639,10 @@ func (m *Ci) DeployApk(
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithFile("/tmp/app.apk", apk).
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
Stdout(ctx)
}
@@ -764,7 +739,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithExec([]string{"pip", "install", "requests", "google-auth"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
@@ -829,7 +804,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
@@ -839,7 +814,6 @@ flowchart TD
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
pubGet --> secrets["CheckSecrets\nage encrypt/decrypt"]
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
codegen --> fmt["Format"]
@@ -853,7 +827,6 @@ flowchart TD
hygiene --> check{{"✓ Check"}}
layers --> check
secrets --> check
fmt --> check
analyze --> check
mocks --> check
@@ -862,25 +835,16 @@ flowchart TD
integration --> check
end
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
ciCheck["check"]
end
buildLinux["build-linux\n(main only)"]
deployPS["deploy-playstore\n(main only)"]
pubWeb["publish-website\n(main only)"]
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
detectChanges["check-changes\ndetect android / linux diff"]
buildLinux["build-linux\n(linux changed)"]
deployPS["deploy-playstore\n(android changed)"]
deployApk["deploy-apk\n(android changed)"]
fbTest["test-android-firebase\n(android changed)"]
pubWeb["publish-website\n(any build succeeded)"]
detectChanges --> buildLinux
detectChanges --> deployPS
detectChanges --> deployApk
detectChanges --> fbTest
ciCheck --> buildLinux
ciCheck --> deployPS
buildLinux --> pubWeb
deployPS --> pubWeb
deployApk --> pubWeb
end
check -- "task check-dagger" --> ciCheck
+2 -6
View File
@@ -87,9 +87,6 @@
# Website
hugo
# Secrets management (master-key encryption for CI sync)
age
# Utilities
git
curl
@@ -97,9 +94,8 @@
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
google-auth
requests
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]);
+15 -16
View File
@@ -4,39 +4,38 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
class UndoService extends Notifier<List<UndoAction>> {
class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
static const int _maxHistory = 10;
// Resolves once build() has loaded persisted history.
late Future<void> _ready;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
@override
List<UndoAction> build() {
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
if (ref.mounted) state = history;
Future<void> init() async {
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
});
return [];
await _ready;
}
/// Waits for the persisted history to finish loading. Called by tests to
/// ensure the provider is ready before asserting state.
Future<void> init() => _ready;
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
}
state = newList;
await ref.read(undoRepositoryProvider).saveAction(action);
await _ref.read(undoRepositoryProvider).saveAction(action);
}
Future<void> clear() async {
await _ready;
state = [];
unawaited(ref.read(undoRepositoryProvider).clearHistory());
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
@@ -58,7 +57,7 @@ class UndoService extends Notifier<List<UndoAction>> {
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
// the local change). The inverse action added below allows undoing the undo.
final repo = ref.read(emailRepositoryProvider);
final repo = _ref.read(emailRepositoryProvider);
for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet).
-3
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -295,7 +294,6 @@ class _AccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') ||
@@ -548,7 +546,6 @@ class _JmapAccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
return s.contains('invalid credentials') ||
s.contains('authentication failed') ||
-4
View File
@@ -6,7 +6,6 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -25,9 +24,6 @@ const _kResourceType = 'background_check';
@pragma('vm:entry-point')
void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((_, __) async {
try {
await _doBackgroundSync();
+1 -46
View File
@@ -609,17 +609,6 @@ Future<String> _resolveDatabasePath() async {
await Future<void>.delayed(Duration(milliseconds: ms));
}
}
// On Android, path_provider can be permanently broken on some devices
// regardless of how long we wait (issue #192). Derive the path from
// /proc/self/cmdline (the Android process name == package name) without
// a platform channel as a last resort so the app can still open its DB.
if (Platform.isAndroid) {
final fallback = await _androidFallbackPath();
if (fallback != null) {
_dbPath = fallback;
return _dbPath!;
}
}
throw PlatformException(
code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
@@ -627,44 +616,10 @@ Future<String> _resolveDatabasePath() async {
);
}
// Reads /proc/self/cmdline to extract the Android package name, then
// constructs the standard app files-dir path without a platform channel.
// Returns null when the path cannot be determined or created.
Future<String?> _androidFallbackPath() async {
try {
final bytes = await File('/proc/self/cmdline').readAsBytes();
final end = bytes.indexOf(0);
final packageName = String.fromCharCodes(
end >= 0 ? bytes.sublist(0, end) : bytes,
).trim();
// A valid Android package name contains dots but not slashes.
if (packageName.isEmpty ||
!packageName.contains('.') ||
packageName.contains('/')) {
return null;
}
for (final base in [
'/data/user/0/$packageName/files',
'/data/data/$packageName/files',
]) {
try {
await Directory(base).create(recursive: true);
return p.join(base, 'sharedinbox.db');
} catch (_) {
continue;
}
}
return null;
} catch (_) {
return null;
}
}
// These functions are only called from unit tests (database_path_test.dart).
// These two functions are only called from unit tests (database_path_test.dart).
// They expose internals that cannot be reached via the public API.
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null;
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
LazyDatabase _openConnection() {
return LazyDatabase(() async {
+12 -11
View File
@@ -11,7 +11,6 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -102,7 +101,7 @@ final searchHistoryRepositoryProvider =
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -182,7 +181,11 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
});
final undoServiceProvider =
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
final service = UndoService(ref);
unawaited(service.init());
return service;
});
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -191,18 +194,16 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new,
);
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
@override
Future<(Email?, EmailBody)> build() async {
Future<(Email?, EmailBody)> build(String emailId) async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(_emailId),
repo.getEmailBody(_emailId),
repo.getEmail(emailId),
repo.getEmailBody(emailId),
]);
unawaited(repo.setFlag(_emailId, seen: true));
unawaited(repo.setFlag(emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody);
}
}
-1
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
+5 -30
View File
@@ -47,14 +47,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
return '## sharedinbox.de\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
@@ -95,30 +91,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
}
}
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched =
await launchUrl(url, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
Future<void> _createIssue(
BuildContext context,
int imapCount,
@@ -191,7 +163,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
_launchUrl(context, Uri.parse(href)),
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
+8 -74
View File
@@ -32,15 +32,11 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr;
String? _errorMessage;
bool _scannerActive = false;
MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override
void initState() {
@@ -65,7 +61,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
);
setState(() {
_keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr;
_step = _Step.showingPubKey;
});
@@ -81,37 +76,8 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
setState(() {
_step = _Step.scanning;
_scannerActive = true;
_scannerController = MobileScannerController();
});
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
}
Future<void> _onScanned(String rawValue) async {
@@ -278,7 +244,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
},
),
const SizedBox(height: 8),
_ExpiryHint(expiresAt: _keyExpiresAt!),
const _ExpiryHint(),
const SizedBox(height: 32),
if (_errorMessage != null) ...[
Text(
@@ -300,14 +266,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}
Widget _buildScannerView(BuildContext context) {
// Fall back to text input when the platform has no camera support or when
// the scanner plugin fails to initialise at runtime (MissingPluginException).
if (!_cameraScanSupported() || _scannerFailed) {
// On platforms where the camera scanner is not available (Linux desktop),
// fall back to a text-input field.
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
@@ -408,37 +371,8 @@ bool _cameraScanSupported() =>
Platform.isMacOS ||
Platform.isWindows;
class _ExpiryHint extends StatefulWidget {
const _ExpiryHint({required this.expiresAt});
final DateTime expiresAt;
@override
State<_ExpiryHint> createState() => _ExpiryHintState();
}
class _ExpiryHintState extends State<_ExpiryHint> {
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String _formatRemaining() {
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
class _ExpiryHint extends StatelessWidget {
const _ExpiryHint();
@override
Widget build(BuildContext context) {
@@ -448,7 +382,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'This key expires in ${_formatRemaining()}',
'This key expires in 20 minutes',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
+2 -35
View File
@@ -45,42 +45,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
bool _scannerActive = true;
MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override
void initState() {
super.initState();
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
_scannerController = MobileScannerController();
}
}
@@ -208,12 +178,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
}
Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported() || _scannerFailed) {
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
+2 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -12,8 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
future: rootBundle.loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
+40 -57
View File
@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -11,42 +10,27 @@ class CrashScreen extends StatelessWidget {
super.key,
required this.exception,
required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
});
final Object exception;
final StackTrace? stackTrace;
final String gitHash;
String get _buildMode {
if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _fetchVersion() async {
try {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
} catch (_) {
return 'unknown';
}
}
static const _gitHash = String.fromEnvironment('GIT_HASH');
Future<String> _buildReport() async {
final version = await _fetchVersion();
String version = 'unknown';
try {
final info = await PackageInfo.fromPlatform();
version = '${info.version}+${info.buildNumber}';
} catch (_) {}
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
final gitLine = _gitHash.isNotEmpty
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
: '';
final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $version\n'
'Build Mode: $_buildMode\n'
'$gitLine'
'Platform: $platform\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
'Platform: $platform\n\n'
'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```';
}
@@ -72,39 +56,12 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
FutureBuilder<String>(
future: _fetchVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
if (gitHash.isNotEmpty) ...[
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'Git Commit: $gitHash',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
const Text(
'Git Commit: $_gitHash',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
@@ -149,6 +106,32 @@ class CrashScreen extends StatelessWidget {
),
),
],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.value?.$1;
final email = next.valueOrNull?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
},
);
final header = detail.value?.$1;
final body = detail.value?.$2;
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
+3 -3
View File
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.value;
final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
+7 -7
View File
@@ -415,10 +415,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -891,10 +891,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "2.6.1"
share_plus:
dependency: "direct main"
description:
@@ -1117,13 +1117,13 @@ packages:
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: "direct overridden"
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.24"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
+1 -5
View File
@@ -24,7 +24,7 @@ dependencies:
path: ^1.9.1
# State management
flutter_riverpod: ^3.0.0
flutter_riverpod: ^2.6.1
# Navigation
go_router: ^17.2.3
@@ -89,7 +89,3 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21"
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
# channel-error on launchUrl on some Android devices (same root cause as
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
url_launcher_android: ">=6.3.0 <6.3.25"
+18 -175
View File
@@ -8,15 +8,12 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
c. Main CI running → save pending-ci state, exit 0
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2a always returns first)
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
g. No Ready issues → print "nothing to do", exit 0
a. CI is running → save pending-ci state, exit 0
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
c. CI ok + pending_issue → close the issue (CI passed), exit 0
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
e. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
@@ -34,7 +31,6 @@ To resume the Claude conversation, look up the session UUID first:
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
@@ -145,21 +141,10 @@ def _ready_issues() -> list[dict]:
return ready
def _latest_main_ci_run() -> dict | None:
"""Return the latest ci.yml run on the main branch.
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
event=push and prettyref=main, so filtering by event alone is not enough.
We also require workflow_id == "ci.yml".
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
def _latest_ci_run() -> dict | None:
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if (run.get("event") == "push"
and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"):
return run
return None
return runs[0] if runs else None
def _latest_ci_run_for_branch(branch: str) -> dict | None:
@@ -179,7 +164,7 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
return run
except (json.JSONDecodeError, AttributeError):
pass
elif run.get("event") == "push":
else:
if run.get("prettyref") == branch:
return run
return None
@@ -203,40 +188,6 @@ def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
return None
def _open_issue_prs() -> list[dict]:
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
prs = json.loads(result.stdout)
issue_prs = []
for pr in prs:
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
issue_prs.append(pr)
issue_prs.sort(key=lambda p: p["number"])
return issue_prs
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
return None
def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
@@ -347,15 +298,6 @@ def _agent_alive(state: dict) -> bool:
return True
def _is_claude_process(pid: int) -> bool:
"""Return True if pid's comm name indicates it is a claude/node process."""
try:
comm = Path(f"/proc/{pid}/comm").read_text().strip()
return comm in ("claude", "node")
except OSError:
return False
def _agent_age_seconds(state: dict) -> float:
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
try:
@@ -390,13 +332,11 @@ def _git_summary() -> str:
def _kill_agent(state: dict) -> None:
"""Forcefully stop the running agent."""
pid = state.get("pid")
if pid and _is_claude_process(pid):
if pid:
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
elif pid:
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
# ── subcommands ───────────────────────────────────────────────────────────────
@@ -534,9 +474,6 @@ def _run_loop() -> int:
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push to the same branch. "
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
"issue via a commit message would be a bug. "
"Verify locally with 'task check' before pushing. "
"When done, stop."
)
@@ -575,25 +512,7 @@ def _run_loop() -> int:
# CI passed on the PR branch — squash-merge and close.
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
)
return 0
if _find_pr_for_branch(branch):
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
"merge command). Please merge manually.",
)
return 0
_merge_pr(pr_number)
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
@@ -619,59 +538,8 @@ def _run_loop() -> int:
)
return 0
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
# This handles PRs whose CI has passed but were never merged because the
# state file was cleared (loop restart, killed agent, manual intervention).
open_prs = _open_issue_prs()
for pr in open_prs:
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
issue_num = int(m.group(1)) if m else None
pr_run = _latest_ci_run_for_pr(pr_number)
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
_write_state(None, issue_num, "pending-ci")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
continue
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
continue
# Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch):
print(
f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry."
)
if issue_num:
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue_num,
f"Automatic merge of PR #{pr_number} failed (PR is still open "
"after the merge command). Please merge manually.",
)
continue
if issue_num:
_close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
else:
print(f"Merged PR #{pr_number}.")
return 0
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
run = _latest_ci_run()
if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
@@ -680,39 +548,17 @@ def _run_loop() -> int:
return 0
if run and run.get("status") in ("failure", "error"):
# Guard: if the same main CI run has been failing since the last ci-fix
# agent started, that agent pushed to a branch instead of main. Before
# spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so.
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
in_flight = [
r for r in (check or {}).get("workflow_runs", [])
if r.get("status") == "running"
]
if in_flight:
print(
f"Main CI still shows the same failed run {run['id']}; "
f"{_ci_run_url(in_flight[0]['id'])} is running "
"(previous ci-fix pushed to a branch). Waiting."
)
return 0
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
prompt = (
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
"The Codeberg CI for guettli/sharedinbox just failed. "
f"The CI run ID is {run['id']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push directly to main. "
"Identify the failure, fix it, commit, and push. "
"Verify locally with 'task check' before pushing. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
"Do NOT close any issues. "
"When done, stop."
)
pid = _start_agent(prompt, "ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
ci_run_id=run["id"] if run else None)
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
return 0
# CI is ok (or no run).
@@ -771,10 +617,7 @@ Instructions:
- Implement the required change, following the existing code style.
- Write or update tests as appropriate.
- Run 'task check' locally and fix any failures before committing.
- Commit with a descriptive message and include (#{issue_number}) in the title,
e.g. "feat: description (#{issue_number})".
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
after CI passes; using those keywords would close it prematurely or wrongly.
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
git checkout -b issue-{issue_number}-fix
git push -u origin issue-{issue_number}-fix
+67 -60
View File
@@ -6,49 +6,76 @@ import os
import sys
import time
import requests
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_MAX_UPLOAD_ATTEMPTS = 3
def _upload_aab_resumable(session, package, edit_id, aab_path):
"""Upload AAB using the Google resumable upload protocol."""
file_size = os.path.getsize(aab_path)
init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
# Step 1: initiate the resumable upload session
init_resp = session.post(
init_url,
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
"Content-Length": "0",
},
timeout=60,
def _make_session(config_json: str) -> AuthorizedSession:
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
return AuthorizedSession(creds)
# Step 2: upload the file in a single PUT to the session URI
with open(aab_path, "rb") as f:
upload_resp = session.put(
upload_url,
data=f,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=600,
)
upload_resp.raise_for_status()
return upload_resp.json()
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
"""Resumable upload of the AAB. Returns the version code."""
file_size = os.path.getsize(AAB_PATH)
with open(AAB_PATH, "rb") as f:
data = f.read()
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
init_resp = session.post(
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
},
json={},
timeout=30,
)
if not init_resp.ok:
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
upload_resp = session.put(
upload_url,
data=data,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=_TIMEOUT,
)
if not upload_resp.ok:
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
upload_resp.raise_for_status()
return upload_resp.json()["versionCode"]
except requests.RequestException as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
time.sleep(delay)
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
def main():
@@ -61,45 +88,25 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
sys.exit(1)
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
session = _make_session(config_json)
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
last_exc = None
bundle = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
break
except Exception as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
f"retrying in {delay}s…"
)
time.sleep(delay)
if bundle is None:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = bundle["versionCode"]
version_code = _upload_aab(session, edit_id)
print(f"Uploaded AAB, version code: {version_code}")
track_resp = session.put(
tracks_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
tracks_resp.raise_for_status()
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
+3
View File
@@ -33,6 +33,9 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
result = subprocess.run(
[
"ssh",
"-v",
"-o", "StrictHostKeyChecking=no",
"-i", "/root/.ssh/id_ed25519",
f"{ssh_user}@{ssh_host}",
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
],
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
# Decrypts secrets.age and exports all KEY=VALUE pairs as environment variables.
#
# In CI (GITHUB_ENV set): writes to $GITHUB_ENV so subsequent job steps can
# read the variables. Multi-line values use the heredoc syntax required by
# Forgejo/GitHub Actions.
#
# Locally: prints an eval-safe export block to stdout. Source it with:
# eval "$(SECRETS_AGE_KEY=$(cat ~/.config/age/sharedinbox.key) scripts/secrets-decrypt.sh)"
# or pass a key file:
# eval "$(scripts/secrets-decrypt.sh ~/.config/age/sharedinbox.key)"
#
# Private key sources (first match wins):
# 1. Path to a key file passed as $1
# 2. SECRETS_AGE_KEY env var (the raw private key content — used in CI)
set -euo pipefail
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|| REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
SECRETS_AGE="${SECRETS_AGE:-${REPO_ROOT}/secrets.age}"
if [ ! -f "$SECRETS_AGE" ]; then
echo "ERROR: secrets.age not found at $SECRETS_AGE" >&2
echo " Run: scripts/secrets-encrypt.sh to create it." >&2
exit 1
fi
TMP_KEY=""
cleanup() { [ -n "$TMP_KEY" ] && rm -f "$TMP_KEY"; }
trap cleanup EXIT
if [ -n "${1:-}" ]; then
KEY_FILE="$1"
elif [ -n "${SECRETS_AGE_KEY:-}" ]; then
TMP_KEY=$(mktemp)
chmod 600 "$TMP_KEY"
printf '%s\n' "$SECRETS_AGE_KEY" > "$TMP_KEY"
KEY_FILE="$TMP_KEY"
else
echo "ERROR: No age private key provided." >&2
echo " Pass a key file: scripts/secrets-decrypt.sh ~/.config/age/sharedinbox.key" >&2
echo " Or set SECRETS_AGE_KEY env var (CI: store as SECRETS_AGE_KEY secret)." >&2
exit 1
fi
DECRYPTED=$(age --decrypt -i "$KEY_FILE" "$SECRETS_AGE")
# Process each KEY=VALUE line.
# Double-quoted values have \n escape sequences converted to real newlines.
process_secrets() {
local line key raw_value value
while IFS= read -r line; do
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
raw_value="${line#*=}"
# Double-quoted: strip quotes and expand \n → newline
if [[ "$raw_value" == '"'*'"' ]]; then
raw_value="${raw_value:1:${#raw_value}-2}"
value=$(printf '%b' "$raw_value")
# Single-quoted: strip quotes, no expansion
elif [[ "$raw_value" == "'"*"'" ]]; then
value="${raw_value:1:${#raw_value}-2}"
else
value="$raw_value"
fi
if [ -n "${GITHUB_ENV:-}" ]; then
# Heredoc syntax handles multi-line values safely
local delim="EOF_${key}_$$"
printf '%s<<%s\n%s\n%s\n' "$key" "$delim" "$value" "$delim" >> "$GITHUB_ENV"
else
# Print as export statements for eval
printf "export %s=%q\n" "$key" "$value"
fi
done <<< "$DECRYPTED"
}
process_secrets
if [ -n "${GITHUB_ENV:-}" ]; then
echo "Secrets written to \$GITHUB_ENV." >&2
fi
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Encrypts secrets.env → secrets.age using an age public key.
#
# Usage:
# scripts/secrets-encrypt.sh [AGE1...] public key as positional argument
# AGE_PUBLIC_KEY=AGE1... scripts/secrets-encrypt.sh
# scripts/secrets-encrypt.sh reads public key from .age-public-key
#
# The private key never touches this script. Only the public key is needed to
# encrypt. Store the private key in CI as SECRETS_AGE_KEY and keep a local
# copy at ~/.config/age/sharedinbox.key (or wherever you prefer).
set -euo pipefail
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|| REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
SECRETS_ENV="${SECRETS_ENV:-${REPO_ROOT}/secrets.env}"
SECRETS_AGE="${SECRETS_AGE:-${REPO_ROOT}/secrets.age}"
KEY_FILE="${REPO_ROOT}/.age-public-key"
if [ -n "${1:-}" ]; then
PUBLIC_KEY="$1"
elif [ -n "${AGE_PUBLIC_KEY:-}" ]; then
PUBLIC_KEY="$AGE_PUBLIC_KEY"
elif [ -f "$KEY_FILE" ]; then
PUBLIC_KEY=$(cat "$KEY_FILE")
PUBLIC_KEY="${PUBLIC_KEY%%$'\n'*}" # take only the first line
else
echo "ERROR: No age public key provided." >&2
echo " Pass it as an argument: scripts/secrets-encrypt.sh AGE1..." >&2
echo " Or store it in .age-public-key: age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key" >&2
exit 1
fi
if [ ! -f "$SECRETS_ENV" ]; then
echo "ERROR: secrets.env not found at $SECRETS_ENV" >&2
echo " Copy secrets.env.example to secrets.env and fill in values." >&2
exit 1
fi
age --encrypt --recipient "$PUBLIC_KEY" --output "$SECRETS_AGE" "$SECRETS_ENV"
echo "Encrypted $SECRETS_ENV$SECRETS_AGE"
echo "Commit secrets.age to keep CI in sync."
+6 -34
View File
@@ -14,42 +14,14 @@ if [ "$host" == "$port" ]; then
port="8774"
fi
MAX_PROBE_ATTEMPTS=5
PROBE_DELAY=30
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active server on $host:$port"
break
fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0
fi
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
sleep $PROBE_DELAY
done
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
timeout 8 dagger version >/dev/null 2>&1; then
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
echo "Dagger configured at tcp://$host:$port (plain TCP)"
fi
exit 0
echo "Probing $host:$port..."
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
echo "Error: No Dagger server responded on $host:$port"
exit 1
fi
echo "Plain TCP connection not available; trying TLS stunnel..."
echo "Found active Dagger server on $host:$port"
# 2b. Setup TLS credentials (passed as env vars from secrets)
# 2. Setup TLS credentials (passed as env vars from secrets)
mkdir -p /tmp/dagger-tls
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
+17 -94
View File
@@ -88,47 +88,21 @@ class TestAgentAlive(unittest.TestCase):
self.assertFalse(agent_loop._agent_alive({"pid": None}))
class TestIsClaudeProcess(unittest.TestCase):
def test_returns_true_for_claude_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_true_for_node_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_false_for_other_process(self):
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
self.assertFalse(agent_loop._is_claude_process(1234))
def test_returns_false_when_proc_missing(self):
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
self.assertFalse(agent_loop._is_claude_process(1234))
class TestKillAgent(unittest.TestCase):
def test_kill_sends_sigkill(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
def test_kill_ignores_missing_process(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
def test_kill_noop_when_no_pid(self):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({})
mock_kill.assert_not_called()
def test_kill_skips_recycled_pid(self):
with patch("agent_loop._is_claude_process", return_value=False):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_not_called()
class TestStartAgent(unittest.TestCase):
def _make_mock_proc(self, pid=42):
@@ -200,8 +174,7 @@ class TestMain(unittest.TestCase):
return 55
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
@@ -227,8 +200,7 @@ class TestMain(unittest.TestCase):
captured["remove"] = remove
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", return_value=99), \
@@ -241,8 +213,7 @@ class TestMain(unittest.TestCase):
def test_no_ready_issues_does_nothing(self):
"""main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._start_agent") as mock_start:
@@ -261,8 +232,7 @@ class TestMain(unittest.TestCase):
return 77
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
@@ -296,9 +266,8 @@ class TestPendingCi(unittest.TestCase):
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
# First call: PR found open. Second call (post-merge verification): PR closed.
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
@@ -313,7 +282,7 @@ class TestPendingCi(unittest.TestCase):
"""'CI passed' line includes the CI run URL when a run is available."""
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \
@@ -423,7 +392,7 @@ class TestPendingCi(unittest.TestCase):
def test_closes_issue_after_ci_fix_and_ci_passes(self):
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
@@ -440,8 +409,7 @@ class TestPendingCi(unittest.TestCase):
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
"type": "ci-fix",
}), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._clear_state"):
@@ -457,8 +425,7 @@ class TestOutputFormat(unittest.TestCase):
def test_output_starts_with_header(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
@@ -469,8 +436,7 @@ class TestOutputFormat(unittest.TestCase):
def test_no_agent_loop_prefix_in_output(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
@@ -480,8 +446,7 @@ class TestOutputFormat(unittest.TestCase):
run = {"id": 4145144, "status": "running"}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \
patch("agent_loop._latest_ci_run", return_value=run), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
@@ -491,8 +456,7 @@ class TestOutputFormat(unittest.TestCase):
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", return_value=99), \
@@ -504,47 +468,6 @@ class TestOutputFormat(unittest.TestCase):
self.assertIn("Fix something", output)
class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
def _ci_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
"status": status, "id": run_id}
def _deploy_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
"status": status, "id": run_id}
def test_skips_deploy_run_returns_ci_run(self):
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
# must be excluded by workflow_id filter.
runs = [self._deploy_run(1), self._ci_run(2)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_returns_none_when_only_deploy_runs_exist(self):
runs = [self._deploy_run(1)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_none_when_only_schedule_runs_exist(self):
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
"status": "success", "id": 1}]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_ci_push_to_main_run(self):
runs = [self._ci_run(42, status="running")]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 42)
class TestLatestCiRunForBranch(unittest.TestCase):
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
-200
View File
@@ -1,200 +0,0 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
def _make_session(
edit_id="edit-42",
version_code=7,
upload_side_effects=None,
):
"""Return a mock AuthorizedSession with sensible defaults."""
session = MagicMock()
# POST /edits → create edit
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": edit_id}
session.post.return_value = edit_resp
# POST resumable-init → Location header
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/session"}
# PUT upload → bundle JSON
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": version_code}
if upload_side_effects is not None:
# Use side_effect list: first call is edit create, rest are upload inits
# We override the PUT side effects via _upload_aab_resumable mock instead
pass
return session, init_resp, upload_resp
class TestMainEnvChecks(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
def test_missing_aab_exits(self):
fake_config = '{"type": "service_account"}'
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=False):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
class TestMainHappyPath(unittest.TestCase):
def _run_main(self, fake_config='{"type":"service_account"}'):
mock_session = MagicMock()
# POST for edit create and commit
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
MagicMock(), # commit
]
mock_session.post.side_effect = post_responses
# PUT for track update
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
return_value={"versionCode": 7},
):
deploy_playstore.main()
return mock_session
def test_creates_edit(self):
session = self._run_main()
create_call = session.post.call_args_list[0]
self.assertIn("/edits", create_call[0][0])
def test_commits_edit(self):
session = self._run_main()
commit_call = session.post.call_args_list[1]
self.assertIn(":commit", commit_call[0][0])
def test_updates_track(self):
session = self._run_main()
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):
mock_session = MagicMock()
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
MagicMock(),
]
mock_session.post.side_effect = post_responses
mock_session.put.return_value = MagicMock()
patches = [
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
patch("deploy_playstore.os.path.exists", return_value=True),
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
patch("deploy_playstore.time.sleep"),
]
for p in patches:
p.start()
try:
deploy_playstore.main()
finally:
for p in patches:
p.stop()
def test_succeeds_on_first_attempt(self):
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
deploy_playstore.main()
mock_upload.assert_called_once()
def test_retries_once_on_error_then_succeeds(self):
self._run_main([ValueError("transient"), {"versionCode": 9}])
def test_raises_after_all_attempts_exhausted(self):
with self.assertRaises(RuntimeError) as ctx:
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
def test_backoff_delays_are_10s_then_20s(self):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
class TestUploadAabResumable(unittest.TestCase):
def test_initiates_and_uploads(self):
mock_session = MagicMock()
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/sess"}
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": 42}
mock_session.post.return_value = init_resp
mock_session.put.return_value = upload_resp
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"fake-aab-content")
aab_path = f.name
try:
result = deploy_playstore._upload_aab_resumable(
mock_session, "com.example.app", "edit-1", aab_path
)
finally:
os.unlink(aab_path)
self.assertEqual(result["versionCode"], 42)
mock_session.post.assert_called_once()
mock_session.put.assert_called_once()
put_call = mock_session.put.call_args
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
if __name__ == "__main__":
unittest.main()
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env bash
# Tests for scripts/secrets-encrypt.sh and scripts/secrets-decrypt.sh.
# Run directly: bash scripts/test_secrets.sh
# Requires: age, age-keygen
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PASS=0
FAIL=0
_assert() {
local name="$1" expected="$2" actual="$3"
if [ "$actual" = "$expected" ]; then
PASS=$((PASS + 1))
else
echo "FAIL: $name"
echo " expected: $(printf '%s' "$expected" | head -c 80)"
echo " actual: $(printf '%s' "$actual" | head -c 80)"
FAIL=$((FAIL + 1))
fi
}
_assert_contains() {
local name="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
PASS=$((PASS + 1))
else
echo "FAIL: $name"
echo " expected to contain: $needle"
echo " actual: $(printf '%s' "$haystack" | head -c 200)"
FAIL=$((FAIL + 1))
fi
}
if ! command -v age >/dev/null 2>&1 || ! command -v age-keygen >/dev/null 2>&1; then
echo "SKIP: age/age-keygen not found — install age to run secrets tests"
exit 0
fi
WORKDIR=$(mktemp -d)
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
KEY_FILE="$WORKDIR/test.key"
SECRETS_ENV="$WORKDIR/secrets.env"
SECRETS_AGE="$WORKDIR/secrets.age"
GITHUB_ENV_FILE="$WORKDIR/github.env"
# Generate a test age key pair
age-keygen -o "$KEY_FILE" 2>/dev/null
PUBLIC_KEY=$(age-keygen -y "$KEY_FILE")
PRIVATE_KEY=$(cat "$KEY_FILE")
# Helper: decrypt and eval, capturing specific variables
_decrypt_vars() {
local vars
vars=$(SECRETS_AGE_KEY="$PRIVATE_KEY" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh")
eval "$vars"
}
# --- simple values ---
cat > "$SECRETS_ENV" << 'EOF'
SIMPLE_VAR=hello
QUOTED_DOUBLE="world"
QUOTED_SINGLE='literal'
EMPTY_VAR=
# comment line — should be ignored
NUMERIC=42
EOF
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$SECRETS_ENV" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-encrypt.sh"
_decrypt_vars
_assert "simple value" "hello" "${SIMPLE_VAR:-}"
_assert "double-quoted value" "world" "${QUOTED_DOUBLE:-}"
_assert "single-quoted value" "literal" "${QUOTED_SINGLE:-}"
_assert "empty value" "" "${EMPTY_VAR:-}"
_assert "numeric value" "42" "${NUMERIC:-}"
unset SIMPLE_VAR QUOTED_DOUBLE QUOTED_SINGLE EMPTY_VAR NUMERIC
# --- multi-line value with \n escape sequences ---
# Use a made-up key format to avoid triggering the detect-private-key pre-commit hook.
printf '%s\n' \
'SSH_KEY="FAKE-KEY-HEADER\nfakekey\nFAKE-KEY-FOOTER"' \
'SIDE=plain' \
> "$SECRETS_ENV"
rm -f "$SECRETS_AGE"
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$SECRETS_ENV" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-encrypt.sh"
_decrypt_vars
_assert_contains "multi-line: header present" "FAKE-KEY-HEADER" "${SSH_KEY:-}"
_assert_contains "multi-line: body present" "fakekey" "${SSH_KEY:-}"
_assert_contains "multi-line: footer present" "FAKE-KEY-FOOTER" "${SSH_KEY:-}"
_assert "variable alongside multi-line" "plain" "${SIDE:-}"
unset SSH_KEY SIDE
# --- GITHUB_ENV output uses heredoc syntax ---
printf '%s\n' 'CI_SECRET=supersecret' > "$SECRETS_ENV"
rm -f "$SECRETS_AGE" "$GITHUB_ENV_FILE"
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$SECRETS_ENV" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-encrypt.sh"
GITHUB_ENV="$GITHUB_ENV_FILE" \
SECRETS_AGE_KEY="$PRIVATE_KEY" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh"
_assert_contains "GITHUB_ENV contains key" "CI_SECRET" "$(cat "$GITHUB_ENV_FILE")"
_assert_contains "GITHUB_ENV contains value" "supersecret" "$(cat "$GITHUB_ENV_FILE")"
# --- missing secrets.age exits non-zero with a helpful message ---
ERR=$(SECRETS_AGE="$WORKDIR/nonexistent.age" \
SECRETS_AGE_KEY="$PRIVATE_KEY" \
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "missing secrets.age: exits non-zero" "1" "$GOT"
_assert_contains "missing secrets.age: error mentions file" "secrets.age" "$ERR"
# --- missing key exits non-zero ---
ERR=$(SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "missing key: exits non-zero" "1" "$GOT"
# --- wrong key fails decryption ---
OTHER_KEY="$WORKDIR/other.key"
age-keygen -o "$OTHER_KEY" 2>/dev/null
ERR=$(SECRETS_AGE_KEY=$(cat "$OTHER_KEY") \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "wrong key: exits non-zero" "1" "$GOT"
# --- encrypt without secrets.env exits non-zero ---
ERR=$(AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$WORKDIR/missing_secrets.env" \
SECRETS_AGE="$WORKDIR/out.age" \
bash "$SCRIPT_DIR/secrets-encrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "encrypt without secrets.env: exits non-zero" "1" "$GOT"
_assert_contains "encrypt without secrets.env: error mentions file" "secrets.env" "$ERR"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
-28
View File
@@ -1,28 +0,0 @@
# Copy this file to secrets.env and fill in real values.
# Then encrypt to secrets.age: scripts/secrets-encrypt.sh
#
# secrets.env — plaintext, git-ignored
# secrets.age — encrypted, committed to the repository
# .age-public-key — age public key, committed (not secret)
#
# Multi-line values (SSH keys, certificates) must be stored as a single line
# with literal \n for newlines, wrapped in double quotes. Example:
# SSH_PRIVATE_KEY="<header line>\n<base64 body lines>\n<footer line>"
#
# One-time setup:
# age-keygen -o ~/.config/age/sharedinbox.key
# age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key
# # Store the private key content in CI as SECRETS_AGE_KEY secret.
ANDROID_KEYSTORE_BASE64=
ANDROID_KEYSTORE_PASSWORD=
PLAY_STORE_CONFIG_JSON=
SSH_PRIVATE_KEY=
SSH_KNOWN_HOSTS=
SSH_USER=
SSH_HOST=
ANDROID_APK_SCP_HOST=
ANDROID_APK_SCP_USER=
ANDROID_APK_SCP_PATH=
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY=
FIREBASE_PROJECT_ID=
-67
View File
@@ -1,8 +1,6 @@
import 'dart:async';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:mockito/annotations.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -32,40 +30,6 @@ void main() {
// This is hard to test without real loops, but we can verify it doesn't crash.
manager.syncNow('unknown');
});
// Regression test for issue #200: when flutter_secure_storage throws
// MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff.
test(
'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async {
final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
syncLog: syncLog,
);
m.start();
// Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1));
m.dispose();
});
}
class FakeEmailRepository implements EmailRepository {
@@ -223,34 +187,3 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {}
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
static const _account = Account(
id: '1',
displayName: 'Test',
email: 'test@example.com',
);
@override
Stream<List<Account>> observeAccounts() => Stream.value([_account]);
@override
Future<Account?> getAccount(String id) async => _account;
@override
Future<String> getPassword(String accountId) => Future.error(
MissingPluginException(
'No implementation found for method read on channel '
'plugins.it.nomads.com/flutter_secure_storage',
),
);
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
}
-23
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
@@ -130,27 +129,5 @@ void main() {
);
});
},
// The Android fallback runs only on Android, so on the host machine the
// exception is still thrown after all retries. Skip on Android to avoid
// depending on /data/user/0/... being absent in the test environment.
skip: Platform.isAndroid,
);
// Regression test for issue #192: _androidFallbackPath must return null when
// the process cmdline does not look like an Android package name (e.g. on
// the host test machine where the process is the Dart executable).
test(
'_androidFallbackPath returns null when process name is not a package name',
() async {
// On non-Android platforms the host process cmdline is a file-system path
// (starts with '/'), which the fallback correctly rejects. On Android
// the process IS named after the package — the fallback is free to
// succeed or return null depending on the device state; we do not assert
// here so as not to constrain Android behaviour.
if (!Platform.isAndroid) {
final result = await androidFallbackPathForTesting();
expect(result, isNull);
}
},
);
}
-40
View File
@@ -27,22 +27,6 @@ class MockUrlLauncher extends Mock
}
}
class ThrowingUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
@override
Future<bool> canLaunch(String? url) async => true;
@override
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: '
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
);
}
}
Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope(
overrides: [
@@ -167,10 +151,6 @@ void main() {
expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts'));
expect(
clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'),
);
});
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
@@ -196,24 +176,4 @@ void main() {
);
expect(mock.launchedUrl, contains('1.2.3%2B99'));
});
testWidgets(
'AboutScreen link tap with failed url_launcher shows error snackbar',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.textContaining('sharedinbox.de').first);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
},
);
}
+2 -100
View File
@@ -23,7 +23,7 @@ void main() {
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
});
testWidgets('shows expiry countdown hint', (tester) async {
testWidgets('shows 20-minute expiry hint', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
@@ -32,106 +32,8 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.textContaining('expires in'), findsOneWidget);
expect(find.textContaining('20 minutes'), findsOneWidget);
});
testWidgets(
'step 2 button shows text-input fallback on platforms without camera',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
// On Linux (desktop, no camera) the text fallback field must appear.
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
},
);
testWidgets(
'step 2 — valid encrypted QR imports account via text fallback',
(tester) async {
// Pre-generate a key pair so we can encrypt a QR code with the same
// material the screen will use for decryption.
final material = await ShareEncryptionService.generateKeyPair();
final repo = FakeShareKeyRepository(material: material);
const account = Account(
id: 'src-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
final encryptedQr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: material.keyId,
recipientPublicKeyBytes: material.publicKeyBytes,
accounts: [
AccountPayload(
accountJson: account.toJson(),
password: 'secret',
),
],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(shareKeyRepository: repo),
),
);
await tester.pumpAndSettle(); // key generation completes
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('encryptedCodeField')),
encryptedQr,
);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(
find.text('Imported 1 account successfully.'),
findsOneWidget,
);
},
);
testWidgets(
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('encryptedCodeField')),
'not-a-valid-qr-code',
);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
// Screen returns to the pub-key step with an error message visible.
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
expect(find.textContaining('Import failed:'), findsWidgets);
},
);
});
group('AccountSendScreen', () {
-54
View File
@@ -1,54 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends CachingAssetBundle {
final Map<String, String> _assets;
_FakeAssetBundle(this._assets);
@override
Future<ByteData> load(String key) async {
if (_assets.containsKey(key)) {
final encoded = utf8.encode(_assets[key]!);
return ByteData.view(Uint8List.fromList(encoded).buffer);
}
throw FlutterError('Asset not found: "$key"');
}
}
const _fakeChangelog =
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
void main() {
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('ChangeLog'), findsOneWidget);
expect(find.textContaining('initial release'), findsOneWidget);
expect(find.textContaining('resolve crash'), findsOneWidget);
expect(find.textContaining('Error loading changelog'), findsNothing);
});
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
}
-1
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
-76
View File
@@ -116,89 +116,13 @@ void main() {
expect(clipboardText, isNotNull);
expect(clipboardText, contains('App Version: 1.0.0+42'));
expect(clipboardText, contains('Build Mode:'));
expect(clipboardText, contains('Platform:'));
expect(clipboardText, contains('Dart:'));
expect(clipboardText, contains('Timestamp:'));
expect(clipboardText, contains('TestException: clipboard test'));
// GIT_HASH is empty in test builds — no Git Commit line expected
expect(clipboardText, isNot(contains('Git Commit:')));
},
);
testWidgets(
'CrashScreen shows git hash as clickable link above stacktrace',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
const exception = 'TestException: git hash test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
// Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(gitLinkFinder, findsOneWidget);
// Link must appear above the stack trace
final stackTraceFinder = find.text('Stack Trace:');
expect(
tester.getTopLeft(gitLinkFinder).dy,
lessThan(tester.getTopLeft(stackTraceFinder).dy),
);
// Tapping the link should open the Codeberg commit URL
await tester.tap(gitLinkFinder);
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
);
},
);
testWidgets(
'CrashScreen shows version, build mode, and platform in the UI',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
const exception = 'TestException: info row test';
final stackTrace = StackTrace.current;
await tester.pumpWidget(
MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace),
),
);
await tester.pumpAndSettle();
// Info row shows app version (from mock), build mode, and platform OS.
expect(find.textContaining('1.0.0+42'), findsWidgets);
// In test builds kDebugMode is true.
expect(find.textContaining('debug'), findsOneWidget);
// Platform OS is always present (linux in CI, android/ios on device).
expect(
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
findsWidgets,
);
},
);
testWidgets(
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
+5 -20
View File
@@ -6,7 +6,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
@@ -20,7 +19,6 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -79,13 +77,11 @@ class FakeAccountRepository implements AccountRepository {
}
class FakeShareKeyRepository implements ShareKeyRepository {
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
ShareKeyMaterial? _material;
@override
Future<ShareKeyMaterial> createKeyPair() async {
_material ??= await ShareEncryptionService.generateKeyPair();
_material = await ShareEncryptionService.generateKeyPair();
return _material!;
}
@@ -477,18 +473,10 @@ Widget buildApp({
);
return ProviderScope(
// Defaults come first so tests can override them via [overrides].
//
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
// framework then fails the test with "A Timer is still pending". Replacing
// these with simple synchronous streams avoids the pending-timer assertion.
// Always neutralise the ManageSieve probe so widget tests never open a
// real socket. Tests that need to assert on probe behaviour should supply
// their own override before this default in [overrides].
overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),
@@ -513,7 +501,6 @@ List<Override> baseOverrides({
List<Mailbox>? mailboxes,
DiscoveryResult? discovery,
Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
}) =>
[
accountRepositoryProvider
@@ -528,9 +515,7 @@ List<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError),
),
shareKeyRepositoryProvider.overrideWithValue(
shareKeyRepository ?? FakeShareKeyRepository(),
),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
];
// ---------------------------------------------------------------------------