Compare commits
36
Commits
@@ -3,7 +3,41 @@ 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:
|
||||
@@ -30,11 +64,48 @@ 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
|
||||
|
||||
+102
-15
@@ -6,15 +6,60 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
check-changes:
|
||||
name: Detect Changed Files
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
android: ${{ steps.diff.outputs.android }}
|
||||
linux: ${{ steps.diff.outputs.linux }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
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
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -31,6 +76,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
if: ${{ secrets.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 }}
|
||||
@@ -45,11 +91,13 @@ 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: 50
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -66,6 +114,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Publish Android to Play Store
|
||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -73,8 +122,38 @@ jobs:
|
||||
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; }
|
||||
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: Build & Deploy APK to server
|
||||
continue-on-error: true
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
@@ -92,11 +171,13 @@ 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: 50
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -113,7 +194,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
continue-on-error: true
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
@@ -128,16 +209,16 @@ jobs:
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore]
|
||||
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -154,7 +235,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
continue-on-error: true
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
@@ -169,8 +250,14 @@ jobs:
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-android-firebase, deploy-playstore, build-linux]
|
||||
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
||||
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'
|
||||
)
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
@@ -179,7 +266,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.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
|
||||
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') }}
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
@@ -11,7 +11,6 @@ jobs:
|
||||
name: Build & Deploy Windows (Nightly)
|
||||
runs-on: windows-runner
|
||||
if: false
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -32,7 +31,6 @@ jobs:
|
||||
|
||||
- name: Set up SSH key
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
@@ -42,7 +40,6 @@ 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 }}
|
||||
|
||||
@@ -91,3 +91,93 @@ The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger m
|
||||
- **Check Suite:** Runs analysis and tests in parallel.
|
||||
- **Builds:** Produces Linux and Android artifacts.
|
||||
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
|
||||
|
||||
## Credential Security — Keeping Production Secrets Off Codeberg
|
||||
|
||||
### Problem
|
||||
|
||||
The current setup stores two categories of secrets in Codeberg repository secrets:
|
||||
|
||||
1. **Dagger access credentials** — TLS certificates used to connect to the remote Dagger engine via stunnel (`DAGGER_CA_CERT`, `DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`, `DAGGER_STUNNEL_URL`).
|
||||
2. **Production secrets** — actual credentials for external services: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `PLAY_STORE_CONFIG_JSON`, `SSH_PRIVATE_KEY`, `FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY`.
|
||||
|
||||
If Codeberg is compromised, both categories are leaked. The Dagger TLS certificates enable access only to the Dagger engine and have limited blast radius. But the production secrets give direct access to the Play Store, the Android signing key, the deployment server, and Firebase — a much larger blast radius.
|
||||
|
||||
**Goal:** Keep only Dagger access credentials in Codeberg. Store all production secrets on the Dagger host machine so they never touch Codeberg.
|
||||
|
||||
### Option 1: Runner-level environment variables
|
||||
|
||||
Store production secrets as environment variables in the Forgejo runner's systemd service (e.g., via a `EnvironmentFile=` in the service override). The runner injects host env vars into job processes automatically. CI workflows drop the `${{ secrets.XYZ }}` references for production secrets entirely — the variables are already present in the job environment.
|
||||
|
||||
**Pro:**
|
||||
- No new infrastructure required.
|
||||
- Works with the existing `dagger call --progress=plain --secret env:VAR_NAME` argument style.
|
||||
- Secrets never enter Codeberg.
|
||||
- Straightforward to set up on a single self-hosted runner.
|
||||
|
||||
**Con:**
|
||||
- Env vars are visible to every process on the runner host (e.g., via `/proc/<pid>/environ`).
|
||||
- Rotating a secret requires host access (no API).
|
||||
- Does not scale cleanly to multiple runners without a shared secrets mechanism.
|
||||
|
||||
### Option 2: Secret files on the CI host with restricted permissions
|
||||
|
||||
Store production secrets as files owned by the runner user with mode `600` (e.g., `/home/forgejo-runner/secrets/play_store.json`). A small setup script reads the files and either exports them as env vars or passes them directly as file-type arguments to `dagger call --progress=plain`. CI workflows contain no secret references at all.
|
||||
|
||||
**Pro:**
|
||||
- OS-level file permissions limit access to the runner user.
|
||||
- Natural format for JSON payloads and key files.
|
||||
- Easy to audit (list files, check mtime).
|
||||
- No new infrastructure.
|
||||
|
||||
**Con:**
|
||||
- Plaintext files on disk; root or backup access exposes them.
|
||||
- Workflow must know file paths (either hardcoded or by convention).
|
||||
- Rotation still requires host filesystem access.
|
||||
|
||||
### Option 3: Dagger host as pipeline orchestrator
|
||||
|
||||
Instead of the CI runner invoking the Dagger CLI directly, the CI job sends a trigger to the Dagger host over SSH. The Dagger host runs the pipeline locally against its own environment, where secrets live as env vars or files. Codeberg only stores the SSH key to reach the Dagger host — not the production secrets.
|
||||
|
||||
```yaml
|
||||
# CI job only does this:
|
||||
- name: Trigger pipeline on Dagger host
|
||||
run: ssh dagger-host "cd sharedinbox && task publish-android"
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.DAGGER_TRIGGER_SSH_KEY }}
|
||||
```
|
||||
|
||||
**Pro:**
|
||||
- Production secrets never leave the Dagger host.
|
||||
- Codeberg stores exactly one secret: the trigger SSH key.
|
||||
- All deployment logic and secrets are fully contained on the host.
|
||||
|
||||
**Con:**
|
||||
- Harder to stream structured CI logs back to Codeberg Actions.
|
||||
- Dynamic context (commit SHA, PR branch) must be passed explicitly over SSH.
|
||||
- The trigger SSH key still grants shell access to the host, so its compromise has its own blast radius.
|
||||
- CI becomes a "fire-and-forget" call, making failure attribution harder.
|
||||
|
||||
### Option 4: External secret manager (e.g., HashiCorp Vault)
|
||||
|
||||
Run a secret manager co-located with the Dagger host. The CI job authenticates with a short-lived AppRole credential (stored in Codeberg) and retrieves secrets at runtime. Vault can also be configured with IP-allow-lists to further restrict who can authenticate.
|
||||
|
||||
**Pro:**
|
||||
- Full audit trail: every secret read is logged with a timestamp and caller identity.
|
||||
- Fine-grained access control per secret.
|
||||
- Built-in versioning and rotation support.
|
||||
- Industry-standard approach; scales to team or multi-runner setups.
|
||||
|
||||
**Con:**
|
||||
- Significant additional infrastructure to install, configure, and maintain.
|
||||
- Vault credentials (RoleID + SecretID) still need to be in Codeberg, though with a smaller blast radius than raw secrets.
|
||||
- Vault itself becomes a security-critical single point of failure.
|
||||
- Operational overhead likely disproportionate for a small single-developer project.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**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 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.
|
||||
|
||||
+12
-1
@@ -284,8 +284,13 @@ 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" "$DAGGER_OUT"; then
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$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
|
||||
@@ -315,6 +320,12 @@ 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]
|
||||
|
||||
+24
-11
@@ -221,7 +221,7 @@ func (m *Ci) pubGetLayer() *dagger.Container {
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^[+~><] ' "$tmp" || true`}).
|
||||
`grep -vE '^(\+|Downloading packages)' "$tmp" || true`}).
|
||||
WithExec([]string{"python3", "-c",
|
||||
"import json, os\n" +
|
||||
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
|
||||
@@ -245,7 +245,7 @@ func (m *Ci) codegenBase() *dagger.Container {
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[' "$tmp" || true`})
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`})
|
||||
}
|
||||
|
||||
// setup overlays platform-specific source files onto the shared codegen base.
|
||||
@@ -312,6 +312,7 @@ func (m *Ci) Hugo() *dagger.Container {
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
|
||||
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
|
||||
WithExec([]string{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}).
|
||||
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
|
||||
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
|
||||
}
|
||||
@@ -410,7 +411,7 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[' "$tmp" || true`}).
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
@@ -649,9 +650,12 @@ func (m *Ci) DeployApk(
|
||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||
built := m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
WithExec([]string{"./gradlew", "app:assembleAndroidTest"}).
|
||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||
WithExec([]string{"./gradlew", "--no-daemon", "app:assembleAndroidTest"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
|
||||
@@ -735,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", "requests", "google-auth"}).
|
||||
WithExec([]string{"pip", "install", "google-auth", "requests"}).
|
||||
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).
|
||||
@@ -831,16 +835,25 @@ flowchart TD
|
||||
integration --> check
|
||||
end
|
||||
|
||||
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
||||
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
|
||||
ciCheck["check"]
|
||||
buildLinux["build-linux\n(main only)"]
|
||||
deployPS["deploy-playstore\n(main only)"]
|
||||
pubWeb["publish-website\n(main only)"]
|
||||
end
|
||||
|
||||
ciCheck --> buildLinux
|
||||
ciCheck --> deployPS
|
||||
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
|
||||
buildLinux --> pubWeb
|
||||
deployPS --> pubWeb
|
||||
deployApk --> pubWeb
|
||||
end
|
||||
|
||||
check -- "task check-dagger" --> ciCheck
|
||||
|
||||
+14
-106
@@ -1,24 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cron deploy script for sharedinbox website.
|
||||
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
|
||||
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
|
||||
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
|
||||
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
|
||||
Forgejo Actions handles failure reporting.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).parent.resolve()
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
|
||||
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
|
||||
ERROR_FILE = REPO_DIR / '.last_deploy_error'
|
||||
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
|
||||
|
||||
MAX_FAILURES = 5
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
REPO = 'guettli/sharedinbox'
|
||||
CODEBERG = 'https://codeberg.org'
|
||||
|
||||
|
||||
def git(*args):
|
||||
@@ -32,115 +25,30 @@ def read(path: Path) -> str:
|
||||
return path.read_text().strip() if path.exists() else ''
|
||||
|
||||
|
||||
def read_int(path: Path) -> int:
|
||||
try:
|
||||
return int(read(path))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def issue_exists_for(sha: str) -> bool:
|
||||
"""Check Codeberg for an open issue referencing this commit SHA."""
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
|
||||
'--limit', '50', '--output', 'simple'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return sha[:8] in result.stdout
|
||||
|
||||
|
||||
def create_issue(failed_sha: str, fail_count: int) -> None:
|
||||
error_output = read(ERROR_FILE)
|
||||
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
|
||||
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
|
||||
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
|
||||
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
|
||||
body = f"""\
|
||||
## Deploy failure — action needed
|
||||
|
||||
The automated deploy cron failed **{fail_count} times** on commit \
|
||||
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Detected** | {timestamp} |
|
||||
| **Failing commit** | [{failed_sha}]({commit_url}) |
|
||||
| **Failures** | {fail_count} / {MAX_FAILURES} |
|
||||
| **Deploy script** | [deploy_cron.py]({script_url}) |
|
||||
| **Log file** | `~/si-deploy-cron/deploy.log` |
|
||||
|
||||
### Last deploy output
|
||||
|
||||
```
|
||||
{tail}
|
||||
```
|
||||
|
||||
### Next steps
|
||||
|
||||
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'create',
|
||||
'--repo', REPO,
|
||||
'--title', title,
|
||||
'--description', body,
|
||||
'--labels', 'State/Ready,Prio/High'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
|
||||
else:
|
||||
print(f'Issue created: {result.stdout.strip()}')
|
||||
|
||||
|
||||
def main():
|
||||
git('fetch', 'origin', 'main')
|
||||
try:
|
||||
git('fetch', 'origin', 'main')
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
|
||||
return
|
||||
remote_sha = git('rev-parse', 'origin/main')
|
||||
|
||||
last_sha = read(SHA_FILE)
|
||||
last_failed = read(FAILED_SHA_FILE)
|
||||
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
|
||||
last_issue = read(ISSUE_SHA_FILE)
|
||||
last_sha = read(SHA_FILE)
|
||||
|
||||
if remote_sha == last_sha:
|
||||
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||
return
|
||||
|
||||
if fail_count >= MAX_FAILURES:
|
||||
if remote_sha != last_issue and not issue_exists_for(remote_sha):
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
|
||||
create_issue(remote_sha, fail_count)
|
||||
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
|
||||
else:
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
|
||||
return
|
||||
|
||||
attempt = fail_count + 1
|
||||
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
|
||||
git('pull', '--ff-only', 'origin', 'main')
|
||||
|
||||
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
|
||||
result = subprocess.run(
|
||||
['task', 'publish-website'],
|
||||
cwd=REPO_DIR,
|
||||
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
print(combined, end='')
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
|
||||
FAILED_SHA_FILE.write_text(remote_sha + '\n')
|
||||
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
|
||||
ERROR_FILE.write_text(combined)
|
||||
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SHA_FILE.write_text(remote_sha + '\n')
|
||||
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
|
||||
f.unlink(missing_ok=True)
|
||||
print('Deploy complete.')
|
||||
print('Workflow triggered.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -94,8 +94,9 @@
|
||||
sqlite
|
||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||
(python3.withPackages (ps: with ps; [
|
||||
google-auth
|
||||
requests
|
||||
google-api-python-client
|
||||
google-auth-httplib2
|
||||
httplib2
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
]);
|
||||
|
||||
@@ -19,6 +19,8 @@ class SyncLogEntry {
|
||||
required this.id,
|
||||
required this.result,
|
||||
this.errorMessage,
|
||||
this.stackTrace,
|
||||
this.isPermanent,
|
||||
required this.protocol,
|
||||
required this.emailsFetched,
|
||||
required this.emailsSkipped,
|
||||
@@ -34,6 +36,8 @@ class SyncLogEntry {
|
||||
final int id;
|
||||
final String result; // 'ok' or 'error'
|
||||
final String? errorMessage;
|
||||
final String? stackTrace;
|
||||
final bool? isPermanent;
|
||||
final String protocol; // 'imap' or 'jmap'
|
||||
final int emailsFetched;
|
||||
final int emailsSkipped;
|
||||
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -13,7 +13,7 @@ Future<void> initNotifications() async {
|
||||
try {
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
await _plugin.initialize(
|
||||
const InitializationSettings(android: android),
|
||||
settings: const InitializationSettings(android: android),
|
||||
onDidReceiveNotificationResponse: (_) {},
|
||||
);
|
||||
await _plugin
|
||||
@@ -31,10 +31,10 @@ Future<void> initNotifications() async {
|
||||
Future<void> showNewMailNotification(String accountEmail) async {
|
||||
if (!Platform.isAndroid || !_initialized) return;
|
||||
await _plugin.show(
|
||||
accountEmail.hashCode & 0x7FFFFFFF,
|
||||
'New mail',
|
||||
accountEmail,
|
||||
const NotificationDetails(
|
||||
id: accountEmail.hashCode & 0x7FFFFFFF,
|
||||
title: 'New mail',
|
||||
body: accountEmail,
|
||||
notificationDetails: const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_kChannelId,
|
||||
_kChannelName,
|
||||
|
||||
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
UndoService(this._ref) : super([]);
|
||||
|
||||
final Ref _ref;
|
||||
class UndoService extends Notifier<List<UndoAction>> {
|
||||
static const int _maxHistory = 10;
|
||||
|
||||
// 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();
|
||||
// Resolves once build() has loaded persisted history.
|
||||
late Future<void> _ready;
|
||||
|
||||
Future<void> init() async {
|
||||
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||
if (mounted) state = history;
|
||||
@override
|
||||
List<UndoAction> build() {
|
||||
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||
if (ref.mounted) state = history;
|
||||
});
|
||||
await _ready;
|
||||
return [];
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -57,7 +58,7 @@ class UndoService extends StateNotifier<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).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -259,6 +260,8 @@ class _AccountSync implements _SyncLoop {
|
||||
accountId: account.id,
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
stackTrace: st.toString(),
|
||||
isPermanent: isPermanent,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
@@ -294,6 +297,7 @@ 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') ||
|
||||
@@ -511,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
accountId: account.id,
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
stackTrace: st.toString(),
|
||||
isPermanent: isPermanent,
|
||||
protocol: 'jmap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
@@ -546,6 +552,7 @@ 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') ||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
@@ -24,6 +25,9 @@ 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();
|
||||
|
||||
@@ -192,6 +192,10 @@ class SyncLogs extends Table {
|
||||
DateTimeColumn get finishedAt => dateTime()();
|
||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||
TextColumn get protocolLog => text().nullable()();
|
||||
// Added in schema v33: stack trace captured when an error occurs.
|
||||
TextColumn get stackTrace => text().nullable()();
|
||||
// Added in schema v33: whether the sync loop stopped permanently after this error.
|
||||
BoolColumn get isPermanent => boolean().nullable()();
|
||||
}
|
||||
|
||||
/// Per-mailbox breakdown for a single sync cycle.
|
||||
@@ -329,7 +333,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 32;
|
||||
int get schemaVersion => 33;
|
||||
|
||||
Future<void> _createEmailFts() async {
|
||||
await customStatement('''
|
||||
@@ -570,6 +574,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 32) {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.stackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -596,8 +604,10 @@ Future<void> initDatabasePath() async {
|
||||
Future<String> _resolveDatabasePath() async {
|
||||
if (_dbPath != null) return _dbPath!;
|
||||
// initDatabasePath() failed (channel not ready before runApp). Retry now
|
||||
// that the engine is fully initialised, with brief back-off.
|
||||
const delays = [100, 300, 600];
|
||||
// that the engine is fully initialised, with back-off. Some slow Android
|
||||
// devices need several seconds for the Pigeon channel to become ready
|
||||
// (issue #166), so use a longer schedule than the initial attempt.
|
||||
const delays = [200, 500, 1000, 2000, 4000];
|
||||
for (final ms in delays) {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
@@ -607,6 +617,17 @@ 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 — '
|
||||
@@ -614,6 +635,45 @@ 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).
|
||||
// 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 {
|
||||
final file = File(await _resolveDatabasePath());
|
||||
|
||||
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
accountId: accountId,
|
||||
result: success ? 'ok' : 'error',
|
||||
errorMessage: Value(errorMessage),
|
||||
stackTrace: Value(stackTrace),
|
||||
isPermanent: Value(isPermanent),
|
||||
protocol: Value(protocol),
|
||||
itemsSynced: Value(emailsFetched),
|
||||
emailsSkipped: Value(emailsSkipped),
|
||||
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
id: r.id,
|
||||
result: r.result,
|
||||
errorMessage: r.errorMessage,
|
||||
stackTrace: r.stackTrace,
|
||||
isPermanent: r.isPermanent,
|
||||
protocol: r.protocol,
|
||||
emailsFetched: r.itemsSynced,
|
||||
emailsSkipped: r.emailsSkipped,
|
||||
|
||||
+11
-12
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLogRepositoryProvider = Provider((ref) {
|
||||
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||
});
|
||||
|
||||
final undoServiceProvider =
|
||||
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
|
||||
final service = UndoService(ref);
|
||||
unawaited(service.init());
|
||||
return service;
|
||||
});
|
||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
||||
EmailDetailNotifier.new,
|
||||
);
|
||||
|
||||
class EmailDetailNotifier
|
||||
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
||||
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
EmailDetailNotifier(this._emailId);
|
||||
final String _emailId;
|
||||
|
||||
@override
|
||||
Future<(Email?, EmailBody)> build(String emailId) async {
|
||||
Future<(Email?, EmailBody)> build() 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -8,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AboutScreen extends ConsumerStatefulWidget {
|
||||
@@ -19,53 +19,16 @@ class AboutScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||
late final Future<Map<String, String>> _deviceInfoFuture =
|
||||
fetchAndroidDeviceInfo();
|
||||
late final Stream<List<Account>> _accountsStream;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||
}
|
||||
|
||||
String _buildMarkdown(
|
||||
BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version =
|
||||
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
return '## sharedinbox.de\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
static String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
Future<void> _copyToClipboard(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
@@ -75,10 +38,12 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final deviceInfo = await _deviceInfoFuture;
|
||||
if (!context.mounted) return;
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
text:
|
||||
buildAboutMarkdown(context, pkg, imapCount, jmapCount, deviceInfo),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
@@ -100,9 +65,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final deviceInfo = await _deviceInfoFuture;
|
||||
if (!context.mounted) return;
|
||||
final body = Uri.encodeComponent(
|
||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
buildAboutMarkdown(context, pkg, imapCount, jmapCount, deviceInfo),
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
@@ -146,18 +112,31 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FutureBuilder<PackageInfo>(
|
||||
future: _packageInfoFuture,
|
||||
child: FutureBuilder<(PackageInfo?, Map<String, String>)>(
|
||||
future: Future.wait([
|
||||
_packageInfoFuture.then<PackageInfo?>((p) => p).catchError(
|
||||
(_) => null,
|
||||
),
|
||||
_deviceInfoFuture,
|
||||
]).then(
|
||||
(results) => (
|
||||
results[0] as PackageInfo?,
|
||||
results[1] as Map<String, String>,
|
||||
),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final pkg = snapshot.data?.$1;
|
||||
final deviceInfo = snapshot.data?.$2 ?? {};
|
||||
return Markdown(
|
||||
data: _buildMarkdown(
|
||||
data: buildAboutMarkdown(
|
||||
context,
|
||||
snapshot.data,
|
||||
pkg,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
deviceInfo,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -13,7 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future: rootBundle.loadString('assets/changelog.txt'),
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -162,7 +162,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _pickAttachments() async {
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
final result = await FilePicker.pickFiles();
|
||||
if (result == null) return;
|
||||
final files = result.files.where((f) => f.path != null).toList();
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -10,10 +10,12 @@ 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;
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
@@ -23,7 +25,11 @@ class CrashScreen extends StatelessWidget {
|
||||
} catch (_) {}
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final gitLine = gitHash.isNotEmpty
|
||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||
: '';
|
||||
return 'App Version: $version\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
@@ -50,6 +56,29 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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 SizedBox(height: 24),
|
||||
const Text(
|
||||
'Error Details:',
|
||||
|
||||
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
||||
emailDetailProvider(widget.emailId),
|
||||
(_, next) {
|
||||
final email = next.valueOrNull?.$1;
|
||||
final email = next.value?.$1;
|
||||
if (email != null && mounted) {
|
||||
setState(() => _isFlagged = email.isFlagged);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final header = detail.valueOrNull?.$1;
|
||||
final body = detail.valueOrNull?.$2;
|
||||
final header = detail.value?.$1;
|
||||
final body = detail.value?.$2;
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
Widget _buildSyncButton(EmailRepository emailRepo) {
|
||||
final isSyncing =
|
||||
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
|
||||
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
|
||||
final hasError =
|
||||
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
|
||||
ref.watch(syncLastErrorProvider(widget.accountId)).value != 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.valueOrNull;
|
||||
final error = errorAsync.value;
|
||||
if (error == null || error == _dismissedError) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
|
||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||
|
||||
@@ -21,6 +25,81 @@ String _fmtBytes(int bytes) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
/// Generates a markdown string for a single sync log entry.
|
||||
String buildSyncLogEntryMarkdown(SyncLogEntry entry) {
|
||||
final timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||
final proto =
|
||||
entry.protocol.isEmpty ? '' : ' (${entry.protocol.toUpperCase()})';
|
||||
final title = entry.isOk
|
||||
? 'Sync OK — ${timeFmt.format(entry.startedAt)}$proto'
|
||||
: 'Sync Error — ${timeFmt.format(entry.startedAt)}$proto';
|
||||
|
||||
final resultLabel = entry.isOk
|
||||
? 'OK'
|
||||
: entry.isPermanent == true
|
||||
? 'Error (permanent — sync stopped)'
|
||||
: 'Error (will retry)';
|
||||
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('## $title');
|
||||
buf.writeln();
|
||||
buf.writeln('| Property | Value |');
|
||||
buf.writeln('|----------|-------|');
|
||||
buf.writeln('| Result | $resultLabel |');
|
||||
if (entry.protocol.isNotEmpty) {
|
||||
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
||||
}
|
||||
buf.writeln('| Started | ${timeFmt.format(entry.startedAt)} |');
|
||||
buf.writeln('| Finished | ${timeFmt.format(entry.finishedAt)} |');
|
||||
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
||||
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
||||
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
||||
|
||||
if (entry.mailboxStats.isNotEmpty) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Per Mailbox');
|
||||
buf.writeln();
|
||||
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
||||
buf.writeln('|---------|---------|------------|----------|');
|
||||
for (final m in entry.mailboxStats) {
|
||||
final dur = m.duration != null ? _fmtDuration(m.duration!) : '—';
|
||||
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.errorMessage != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Error Message');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.writeln(entry.errorMessage);
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
if (entry.stackTrace != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Stack Trace');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.writeln(entry.stackTrace);
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
if (entry.protocolLog != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Protocol Log');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.writeln(entry.protocolLog);
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
return buf.toString().trimRight();
|
||||
}
|
||||
|
||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||
const SyncLogScreen({super.key, required this.accountId});
|
||||
|
||||
@@ -69,6 +148,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||
}
|
||||
|
||||
Future<void> _copyEntry(SyncLogEntry entry) async {
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await PackageInfo.fromPlatform();
|
||||
} catch (_) {}
|
||||
final deviceInfo = await fetchAndroidDeviceInfo();
|
||||
if (!mounted) return;
|
||||
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
if (!mounted) return;
|
||||
final entryMd = buildSyncLogEntryMarkdown(entry);
|
||||
final aboutMd = buildAboutMarkdown(
|
||||
context,
|
||||
pkg,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
deviceInfo,
|
||||
);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: '$entryMd\n\n---\n\n$aboutMd'),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 3),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -96,16 +210,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
? const Center(child: Text('No sync entries yet'))
|
||||
: ListView.builder(
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(
|
||||
entry: _entries[i],
|
||||
onCopy: () => unawaited(_copyEntry(_entries[i])),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncLogTile extends StatelessWidget {
|
||||
const _SyncLogTile({required this.entry});
|
||||
const _SyncLogTile({required this.entry, required this.onCopy});
|
||||
|
||||
final SyncLogEntry entry;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -127,9 +245,20 @@ class _SyncLogTile extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: 'Error · took $durationLabel',
|
||||
: 'Error${entry.isPermanent == true ? ' (permanent)' : ''} · took $durationLabel',
|
||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy to clipboard',
|
||||
onPressed: onCopy,
|
||||
),
|
||||
const Icon(Icons.expand_more),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
||||
@@ -171,6 +300,18 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: TextStyle(color: errorColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (entry.stackTrace != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
entry.stackTrace!,
|
||||
style: TextStyle(
|
||||
color: errorColor,
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry.protocolLog != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
/// Returns Android device info (manufacturer, model, OS release).
|
||||
/// Returns an empty map on non-Android platforms or if the plugin fails.
|
||||
Future<Map<String, String>> fetchAndroidDeviceInfo() async {
|
||||
if (!Platform.isAndroid) return {};
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
return {
|
||||
'Manufacturer': info.manufacturer,
|
||||
'Model': info.model,
|
||||
'Android Version': info.version.release,
|
||||
};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
/// Builds the standard "about" markdown table for sharing / bug reports.
|
||||
///
|
||||
/// Pass [deviceInfo] from [fetchAndroidDeviceInfo].
|
||||
/// Pass [imapCount] and [jmapCount] when available; both default to 0.
|
||||
String buildAboutMarkdown(
|
||||
BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
Map<String, String> deviceInfo,
|
||||
) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
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'
|
||||
: '';
|
||||
|
||||
final deviceLines =
|
||||
deviceInfo.entries.map((e) => '| ${e.key} | ${e.value} |\n').join();
|
||||
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'$gitCommitLine'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'$deviceLines'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
+84
-44
@@ -249,6 +249,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.1.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -313,6 +329,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -325,10 +349,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
version: "12.0.0-beta.4"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -351,34 +375,42 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
version: "21.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "8.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
version: "11.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -399,34 +431,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
|
||||
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "10.3.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb"
|
||||
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
version: "0.3.2"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -447,10 +479,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "4.2.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -486,10 +518,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
version: "17.2.3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -587,10 +619,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -643,10 +675,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
version: "7.2.0"
|
||||
mockito:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -699,18 +731,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "10.1.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "4.1.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -875,26 +907,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.2.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
|
||||
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.2"
|
||||
version: "13.1.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.1.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -976,10 +1008,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
version: "0.6.0+eol"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1080,10 +1112,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
version: "0.11.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1104,10 +1136,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1264,10 +1296,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
version: "6.3.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+10
-9
@@ -19,15 +19,15 @@ dependencies:
|
||||
|
||||
# Local persistence (offline-first)
|
||||
drift: ^2.20.3
|
||||
sqlite3_flutter_libs: ^0.5.28
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
|
||||
# State management
|
||||
flutter_riverpod: ^2.6.1
|
||||
flutter_riverpod: ^3.0.0
|
||||
|
||||
# Navigation
|
||||
go_router: ^14.8.1
|
||||
go_router: ^17.2.3
|
||||
|
||||
# Secure credential storage (passwords)
|
||||
flutter_secure_storage: ^10.0.0
|
||||
@@ -36,7 +36,7 @@ dependencies:
|
||||
intl: any
|
||||
|
||||
# File picking (compose attachments) and opening downloaded attachments
|
||||
file_picker: ^8.0.0
|
||||
file_picker: ^12.0.0-beta.4
|
||||
open_filex: ^4.6.0
|
||||
mime: ^2.0.0
|
||||
|
||||
@@ -47,7 +47,7 @@ dependencies:
|
||||
cryptography: ^2.7.0
|
||||
|
||||
# QR code scanning (camera) for secure account import
|
||||
mobile_scanner: ^5.0.0
|
||||
mobile_scanner: ^7.2.0
|
||||
|
||||
# HTML rendering for email bodies
|
||||
webview_flutter: ^4.0.0
|
||||
@@ -55,19 +55,20 @@ dependencies:
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
|
||||
# Background sync and local notifications
|
||||
flutter_local_notifications: ^18.0.1
|
||||
flutter_local_notifications: ^21.0.0
|
||||
workmanager: ^0.9.0
|
||||
|
||||
# App version metadata for crash reports
|
||||
package_info_plus: ^8.0.0
|
||||
share_plus: ^12.0.2
|
||||
package_info_plus: ^10.1.0
|
||||
share_plus: ^13.1.0
|
||||
device_info_plus: ^13.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
drift_dev: ^2.20.3
|
||||
build_runner: ^2.4.13
|
||||
test: ^1.25.0
|
||||
|
||||
+222
-25
@@ -8,12 +8,15 @@ 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. 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
|
||||
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
|
||||
|
||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||
|
||||
@@ -22,14 +25,16 @@ State file: ~/.sharedinbox-agent-state.json
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||
|
||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||
Resume the Claude conversation afterward with:
|
||||
To resume the Claude conversation, look up the session UUID first:
|
||||
|
||||
claude --resume issue-91
|
||||
scripts/agent_loop.py list # shows NAME and UUID columns
|
||||
claude --resume <uuid> # use the UUID, NOT the session name
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -140,10 +145,19 @@ def _ready_issues() -> list[dict]:
|
||||
return ready
|
||||
|
||||
|
||||
def _latest_ci_run() -> dict | None:
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest CI run on the main branch (excludes PR runs).
|
||||
|
||||
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||
on a PR branch could mask the true state of main. We filter to non-PR
|
||||
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
return runs[0] if runs else None
|
||||
for run in runs:
|
||||
if run.get("event") != "pull_request" and run.get("prettyref") == "main":
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
@@ -169,11 +183,11 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_pr_for_branch(branch: str) -> dict | None:
|
||||
"""Return the first open PR whose head branch matches, or None."""
|
||||
def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
|
||||
"""Return the first PR in the given state whose head branch matches, or None."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
"--repo", REPO, "--state", state, "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
@@ -187,6 +201,40 @@ def _find_pr_for_branch(branch: str) -> 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")
|
||||
@@ -225,6 +273,30 @@ def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _find_session_uuid(session_name: str) -> str | None:
|
||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||
|
||||
Claude stores session metadata in JSONL files; the first entry with
|
||||
type=="agent-name" contains both the human-readable name and the UUID
|
||||
needed for ``claude --resume <uuid>``.
|
||||
"""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
return None
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
|
||||
return d.get("sessionId")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -255,7 +327,7 @@ def _start_agent(prompt: str, session_name: str) -> int:
|
||||
proc.stdin.close()
|
||||
|
||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f" Resume: claude --resume {shlex.quote(session_name)}")
|
||||
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
|
||||
return proc.pid
|
||||
|
||||
|
||||
@@ -399,7 +471,13 @@ def _run_loop() -> int:
|
||||
return 1
|
||||
|
||||
session_name = state.get("session_name")
|
||||
resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else ""
|
||||
uuid = _find_session_uuid(session_name) if session_name else None
|
||||
if uuid:
|
||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||
elif session_name:
|
||||
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
||||
else:
|
||||
resume_cmd = ""
|
||||
git_info = _git_summary()
|
||||
parts = [
|
||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||
@@ -443,6 +521,9 @@ 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."
|
||||
)
|
||||
@@ -480,14 +561,104 @@ def _run_loop() -> int:
|
||||
return 0
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed on branch {branch!r} — merging PR #{pr_number}.")
|
||||
_merge_pr(pr_number)
|
||||
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
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||
run = _latest_ci_run()
|
||||
# No open PR — check if it was already merged.
|
||||
merged_pr = _find_pr_for_branch(branch, state="closed")
|
||||
if merged_pr and merged_pr.get("merged"):
|
||||
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
|
||||
_close_issue(pending_issue)
|
||||
return 0
|
||||
|
||||
# No open or merged PR — the agent may not have created one, or it was
|
||||
# closed without merging (the bug this block was added to catch).
|
||||
print(
|
||||
f"No open or merged PR found for branch {branch!r} "
|
||||
f"(issue #{pending_issue}) — setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
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()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
@@ -496,17 +667,39 @@ 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. "
|
||||
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
|
||||
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. "
|
||||
"Identify the failure, fix it, commit, and push directly to main. "
|
||||
"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")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
|
||||
ci_run_id=run["id"] if run else None)
|
||||
return 0
|
||||
|
||||
# CI is ok (or no run).
|
||||
@@ -528,7 +721,8 @@ def _run_loop() -> int:
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
print(f"CI passed — closed {_issue_url(pending_issue)}.")
|
||||
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
|
||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# Find a Ready issue.
|
||||
@@ -564,7 +758,10 @@ 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 referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -57,6 +57,7 @@ const _excluded = {
|
||||
'lib/ui/widgets/try_connection_button.dart',
|
||||
'lib/ui/widgets/undo_shell.dart',
|
||||
'lib/ui/screens/about_screen.dart',
|
||||
'lib/ui/utils/about_markdown.dart',
|
||||
'lib/ui/widgets/email_tile.dart',
|
||||
'lib/core/sync/account_sync_manager.dart',
|
||||
'lib/core/sync/background_sync.dart',
|
||||
|
||||
+60
-67
@@ -6,76 +6,49 @@ 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 _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"],
|
||||
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,
|
||||
)
|
||||
return AuthorizedSession(creds)
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
|
||||
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
|
||||
# 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 main():
|
||||
@@ -88,25 +61,45 @@ def main():
|
||||
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
session = _make_session(config_json)
|
||||
|
||||
edit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits",
|
||||
json={},
|
||||
timeout=30,
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
)
|
||||
session = AuthorizedSession(creds)
|
||||
|
||||
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||
edit_resp.raise_for_status()
|
||||
edit_id = edit_resp.json()["id"]
|
||||
|
||||
version_code = _upload_aab(session, edit_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"]
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
tracks_resp = session.put(
|
||||
track_resp = session.put(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
timeout=30,
|
||||
)
|
||||
tracks_resp.raise_for_status()
|
||||
track_resp.raise_for_status()
|
||||
|
||||
commit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||
|
||||
@@ -14,14 +14,42 @@ if [ "$host" == "$port" ]; then
|
||||
port="8774"
|
||||
fi
|
||||
|
||||
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 "Found active Dagger server on $host:$port"
|
||||
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
|
||||
|
||||
# 2. Setup TLS credentials (passed as env vars from secrets)
|
||||
# 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
|
||||
fi
|
||||
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||
|
||||
# 2b. 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
|
||||
|
||||
+216
-11
@@ -256,21 +256,88 @@ class TestPendingCi(unittest.TestCase):
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def _find_pr_open(self, branch, state="open"):
|
||||
if state == "open":
|
||||
return self._open_pr(branch)
|
||||
return None
|
||||
|
||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||
"""After issue agent finishes, loop closes the issue once CI is green."""
|
||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
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, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(5)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_does_not_close_issue_when_ci_fails(self):
|
||||
"""After issue agent finishes, loop must NOT close the issue if CI failed."""
|
||||
def test_ci_passed_output_includes_ci_run_url(self):
|
||||
"""'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._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||
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"), \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
||||
|
||||
def test_already_merged_pr_closes_issue_without_ci_url(self):
|
||||
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
|
||||
def find_pr(branch, state="open"):
|
||||
if state == "closed":
|
||||
return {"number": 5, "merged": True}
|
||||
return None
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
result = agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
self.assertIn("already merged", output)
|
||||
self.assertNotIn("/actions/runs/", output)
|
||||
|
||||
def test_no_pr_found_sets_question_label(self):
|
||||
"""When no open or merged PR exists for the pending branch, set State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
|
||||
|
||||
def test_does_not_close_issue_when_ci_fails(self):
|
||||
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
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": "failure"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state"), \
|
||||
@@ -281,7 +348,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
mock_close.assert_not_called()
|
||||
|
||||
def test_saves_pending_ci_state_while_ci_running(self):
|
||||
"""When CI is still running after agent finishes, pending issue is preserved."""
|
||||
"""When CI is still running on PR branch after agent finishes, pending issue is preserved."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
@@ -290,7 +357,8 @@ class TestPendingCi(unittest.TestCase):
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "running"}), \
|
||||
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": "running"}), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
@@ -301,7 +369,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
self.assertIsNone(written.get("pid"))
|
||||
|
||||
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||
"""When CI fails after agent finishes, ci-fix state includes the pending issue."""
|
||||
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
@@ -310,7 +378,8 @@ class TestPendingCi(unittest.TestCase):
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||
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": "failure"}), \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
@@ -321,14 +390,17 @@ class TestPendingCi(unittest.TestCase):
|
||||
self.assertEqual(written.get("kind"), "ci-fix")
|
||||
|
||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||
"""After ci-fix agent finishes and CI passes, the pending issue is closed."""
|
||||
"""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._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
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, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(5)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_no_pending_issue_ci_fix_without_issue(self):
|
||||
@@ -462,5 +534,138 @@ class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
self.assertEqual(result["id"], 10)
|
||||
|
||||
|
||||
class TestFindSessionUuid(unittest.TestCase):
|
||||
"""Tests for _find_session_uuid()."""
|
||||
|
||||
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
|
||||
path = directory / filename
|
||||
with path.open("w") as fh:
|
||||
for entry in entries:
|
||||
fh.write(json.dumps(entry) + "\n")
|
||||
return path
|
||||
|
||||
def test_returns_uuid_for_matching_session_name(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-abc-123")
|
||||
|
||||
def test_returns_none_when_name_does_not_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_directory_missing(self):
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_no_agent_name_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "message", "content": "hello"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_scans_multiple_files_to_find_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "aaa.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
|
||||
])
|
||||
self._write_jsonl(projects_dir, "bbb.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-91")
|
||||
|
||||
|
||||
class TestRunLoopResumeCommand(unittest.TestCase):
|
||||
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
|
||||
|
||||
def _alive_state(self, session_name="issue-91"):
|
||||
return {
|
||||
"pid": os.getpid(), # own PID is always alive
|
||||
"issue": 91,
|
||||
"started_at": "2026-05-23T12:00:00+00:00",
|
||||
"type": "issue",
|
||||
"session_name": session_name,
|
||||
}
|
||||
|
||||
def test_resume_shows_uuid_when_found(self):
|
||||
buf = io.StringIO()
|
||||
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn(f"claude --resume {fake_uuid}", output)
|
||||
|
||||
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("scripts/agent_loop.py list", output)
|
||||
# Must NOT show the session name as a valid resume argument.
|
||||
self.assertNotIn("claude --resume issue-91", output)
|
||||
|
||||
def test_resume_not_shown_when_no_session_name(self):
|
||||
state = self._alive_state()
|
||||
del state["session_name"]
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=state), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertNotIn("Resume:", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/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()
|
||||
@@ -288,6 +288,8 @@ class _FakeLogs implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
@@ -30,6 +32,40 @@ 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 {
|
||||
@@ -145,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -187,3 +225,34 @@ 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 {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
// Fake PathProviderPlatform that always throws PlatformException(channel-error)
|
||||
// to simulate the Pigeon channel not being ready at startup (issue #166).
|
||||
class _UnavailablePathProvider extends Fake
|
||||
with MockPlatformInterfaceMixin
|
||||
implements PathProviderPlatform {
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Simulated: path_provider channel not ready',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fake PathProviderPlatform that fails the first [failCount] calls, then
|
||||
// returns a fixed path. Used to exercise the retry loop in
|
||||
// _resolveDatabasePath() without waiting for real timers.
|
||||
class _SucceedAfterNPathProvider extends Fake
|
||||
with MockPlatformInterfaceMixin
|
||||
implements PathProviderPlatform {
|
||||
_SucceedAfterNPathProvider({required this.failCount});
|
||||
|
||||
final int failCount;
|
||||
int _callCount = 0;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async {
|
||||
_callCount++;
|
||||
if (_callCount <= failCount) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Simulated: path_provider channel not ready',
|
||||
);
|
||||
}
|
||||
return '/tmp/test_app_support';
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/166:
|
||||
// On some slow Android devices the path_provider Pigeon channel is not ready
|
||||
// when initDatabasePath() runs before runApp(). initDatabasePath() must
|
||||
// absorb the PlatformException and let the app start; _resolveDatabasePath()
|
||||
// then retries with back-off on first DB access.
|
||||
test(
|
||||
'initDatabasePath completes without throwing when path_provider is unavailable',
|
||||
() async {
|
||||
final prev = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _UnavailablePathProvider();
|
||||
addTearDown(() => PathProviderPlatform.instance = prev);
|
||||
|
||||
// Must not throw — the exception is swallowed so the app can continue.
|
||||
await expectLater(initDatabasePath(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
|
||||
// access when initDatabasePath() already failed. fake_async lets us advance
|
||||
// the back-off timers without waiting real-world milliseconds.
|
||||
|
||||
test(
|
||||
'_resolveDatabasePath retries and eventually succeeds after transient failures',
|
||||
() {
|
||||
resetDatabasePathForTesting();
|
||||
final prev = PathProviderPlatform.instance;
|
||||
// Fail 3 times, succeed on the 4th call. The delays in
|
||||
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
|
||||
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
|
||||
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
|
||||
addTearDown(() {
|
||||
PathProviderPlatform.instance = prev;
|
||||
resetDatabasePathForTesting();
|
||||
});
|
||||
|
||||
fakeAsync((fake) {
|
||||
String? result;
|
||||
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
|
||||
|
||||
// Advance fake time through the three back-off delays.
|
||||
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result, endsWith('sharedinbox.db'));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'_resolveDatabasePath throws PlatformException after exhausting all retries',
|
||||
() {
|
||||
resetDatabasePathForTesting();
|
||||
final prev = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _UnavailablePathProvider();
|
||||
addTearDown(() {
|
||||
PathProviderPlatform.instance = prev;
|
||||
resetDatabasePathForTesting();
|
||||
});
|
||||
|
||||
fakeAsync((fake) {
|
||||
Object? caughtError;
|
||||
unawaited(
|
||||
resolveDatabasePathForTesting().catchError((Object e) {
|
||||
caughtError = e;
|
||||
return ''; // ignored; satisfies the Future<String> return type
|
||||
}),
|
||||
);
|
||||
|
||||
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
|
||||
fake.elapse(
|
||||
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
|
||||
);
|
||||
|
||||
expect(caughtError, isA<PlatformException>());
|
||||
expect(
|
||||
(caughtError! as PlatformException).message,
|
||||
contains('cannot open database'),
|
||||
);
|
||||
});
|
||||
},
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 32);
|
||||
expect(db.schemaVersion, 33);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -194,6 +194,10 @@ void main() {
|
||||
// v32: local_sieve_applied table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
||||
|
||||
// v33: stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -381,11 +385,15 @@ void main() {
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 32', () async {
|
||||
test('fresh install creates all tables at schemaVersion 33', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -426,6 +434,10 @@ void main() {
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, containsAll(['stack_trace', 'is_permanent']));
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,6 +170,8 @@ class _FakeSyncLog implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool? isPermanent,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -126,4 +126,56 @@ void main() {
|
||||
expect(rows.first.result, 'error');
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
});
|
||||
|
||||
test('stores and retrieves stack trace and isPermanent', () async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 10);
|
||||
final end = DateTime(2024, 3, 1, 10, 0, 1);
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: '#0 MethodChannel._invokeMethod\n#1 main',
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 0,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final latest = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(latest.stackTrace, '#0 MethodChannel._invokeMethod\n#1 main');
|
||||
expect(latest.isPermanent, isTrue);
|
||||
expect(latest.errorMessage, 'MissingPluginException');
|
||||
});
|
||||
|
||||
test('isPermanent is null for success entries', () async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 4, 1, 10);
|
||||
final end = DateTime(2024, 4, 1, 10, 0, 3);
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 2,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 1,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final latest = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(latest.isPermanent, isNull);
|
||||
expect(latest.stackTrace, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ 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', (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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 Fake implements AssetBundle {
|
||||
final String content;
|
||||
_FakeAssetBundle(this.content);
|
||||
|
||||
@override
|
||||
Future<String> loadString(String key, {bool cache = true}) async {
|
||||
if (key == 'assets/changelog.txt') return content;
|
||||
throw FlutterError('Asset not found: $key');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
throw FlutterError('Asset not found: $key');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen renders changelog content', (tester) async {
|
||||
const fakeChangelog =
|
||||
'* 2026-01-01: Initial release\n* 2026-01-02: Bug fix';
|
||||
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle(fakeChangelog),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ChangeLog'), findsOneWidget);
|
||||
expect(find.textContaining('2026-01-01'), findsOneWidget);
|
||||
expect(find.textContaining('Initial release'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _BadAssetBundle(),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error loading changelog:'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _BadAssetBundle extends Fake implements AssetBundle {
|
||||
@override
|
||||
Future<String> loadString(String key, {bool cache = true}) async {
|
||||
throw FlutterError('Unable to load asset: "$key"');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
throw FlutterError('Unable to load asset: "$key"');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -76,6 +77,96 @@ void main() {
|
||||
expect(mock.launchedUrl, isNot(contains('Stack%20Trace')));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen copy-to-clipboard includes version and platform info',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
String? clipboardText;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
clipboardText =
|
||||
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const exception = 'TestException: clipboard test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Copy to Clipboard'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||
expect(clipboardText, contains('Platform:'));
|
||||
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 used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
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: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/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
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';
|
||||
@@ -19,6 +20,7 @@ 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';
|
||||
@@ -473,10 +475,18 @@ Widget buildApp({
|
||||
);
|
||||
|
||||
return ProviderScope(
|
||||
// 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].
|
||||
// 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.
|
||||
overrides: [
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
syncLogRepositoryProvider.overrideWithValue(
|
||||
const NoOpSyncLogRepository(),
|
||||
),
|
||||
...overrides,
|
||||
manageSieveProbeServiceProvider.overrideWith(
|
||||
(ref) => _NoOpManageSieveProbeService(),
|
||||
|
||||
Reference in New Issue
Block a user