Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 5b93a59537 fix: survive permanently broken path_provider channel on Android (#192)
Two changes prevent the crash reported on Samsung S1RXS32.50-13-25:

1. Add WidgetsFlutterBinding.ensureInitialized() to callbackDispatcher so
   that Flutter platform channels (including path_provider) are available
   when WorkManager triggers background sync.  Without it the channel is
   permanently unavailable in that isolate regardless of how long we wait.

2. Add an Android-specific fallback in _resolveDatabasePath: after all
   back-off retries fail, derive the app files-dir path from
   /proc/self/cmdline (the Android process name equals the package name)
   without a platform channel.  This lets the database open on devices
   where path_provider is broken even in the main isolate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:41:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 72b25c87ac fix(ci): retry on 'invalid return status code' Dagger disconnect
Adds the Dagger gRPC/HTTP disconnect error to the retry pattern
so transient engine drops during long-running steps (like build_runner)
auto-recover instead of failing the CI job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:15:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 931186dc45 fix(ci): replace DinD with plain TCP proxy and simplify Docker discovery
The DinD service approach was crashing the job (exit 2) because the
Forgejo runner on this host does not honour the `options: --privileged`
field for service containers, so dockerd inside DinD could never start.

Root cause of the broader CI failure: dagger-stunnel.service stopped
cleanly (exit 0 → no auto-restart), leaving port 8774 without a
listener. A plain socat TCP proxy (8774→1774) is now running on the
host as a stop-gap until stunnel is restarted.

Changes:
- Remove the docker:27-dind service container from ci.yml entirely
- Simplify "Locate Docker daemon" step — warn instead of failing when
  Docker is unavailable (job fails later at the Dagger step with a
  clearer message)
- Add plain-TCP path to setup_dagger_remote.sh: after a successful nc
  probe, try `dagger version` directly over the target host:port before
  falling back to the TLS stunnel setup; this works with both the socat
  plain-TCP proxy and any future plain-TCP Dagger engine exposure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:57:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 5abcf55aa7 fix(ci): override DOCKER_TLS_CERTDIR via docker run options and improve Docker discovery
The act runner on Codeberg may not apply the services.env block to the
DinD container, so DOCKER_TLS_CERTDIR defaults to /certs and dockerd
starts with TLS on port 2376 instead of 2375. Fix by passing
--env DOCKER_TLS_CERTDIR= directly via options: so it is always applied
at docker run time.

Also:
- Try the host Docker socket (DooD) first before DinD; many self-hosted
  runners mount /var/run/docker.sock and this is simpler and more reliable.
- Remove the workflow-level DOCKER_HOST override; let the step discover
  and export the correct value instead of pre-forcing tcp://docker:2375.
- Retry DinD by hostname up to 60 s before falling back to scanning.
- Add DNS resolution check (getent hosts docker) and a port 2376 probe
  that surfaces the TLS-still-enabled diagnostic message clearly.
- Improve final diagnostics (IPs, DNS, socket path) to aid future debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:10:25 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 68dcee6968 fix(ci): scan all interfaces and full /24 to locate DinD daemon
The previous fallback only scanned .1-.50 of the first interface's
subnet, missing the DinD container when its IP is higher (.51+) or
when the forgejo-jobs network is on a different interface than
hostname -I returned first.

Now iterates all non-loopback IPs from hostname -I, scans each
subnet's full /24 (.1-254), and uses a 0.3 s bash /dev/tcp probe
instead of nc -zw1 to keep the total scan time under ~80 s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:52:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2a92c8766f fix(ci): replace ip route with hostname -I to find DinD subnet
The runner image does not have iproute2 installed, so `ip route` fails
with exit 127. Use `hostname -I` (available everywhere) to get the
container's own IP and derive the /24 prefix for the DinD port scan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:37:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 49ad2ff25d fix(ci): add --privileged to DinD and fallback IP scan for docker hostname
The docker:27-dind service container needs --privileged to start dockerd;
without it the container exits immediately and its DNS alias is removed,
causing the embedded DNS to return SERVFAIL for 'docker'.

Codeberg's act runner may also not register the service key as a network
alias at all. Add a 'Locate Docker daemon' step that tries the configured
DOCKER_HOST first, then falls back to scanning the local /24 for port 2375
so the local Dagger engine can connect to DinD regardless.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:27:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c487714b63 fix(ci): add DinD service so local Dagger fallback works when remote engine is down
When the remote Dagger engine (stunnel/port 8774) is unreachable, Dagger
falls back to a local engine which requires a Docker daemon. The job container
does not have /var/run/docker.sock mounted, so the fallback was failing with
"connect: no such file or directory".

Add a docker:27-dind service to the CI job and set DOCKER_HOST=tcp://docker:2375
so Dagger can start a local engine when the remote engine is unavailable.

Also guard the Firebase and Play Store steps in deploy.yml so they are skipped
gracefully when the relevant secrets are not configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:12:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f560d9d921 fix(ci): fall back to local Dagger engine when remote is unreachable
The remote Dagger engine probe exits with an error when the server is
down, failing CI before any tests run. Change the probe to exit 0 on
timeout and print a warning instead; with _DAGGER_RUNNER_HOST unset
Dagger will start a local engine and CI can still complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:00:52 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9eba422c67 fix(ci): retry Dagger engine probe and prune cache after check
The Dagger engine stopped responding (connection refused) after the
previous run exhausted disk space and crashed it. Two changes:

1. setup_dagger_remote.sh: retry the nc probe up to 5 times with 30 s
   delays so a transient crash/restart window doesn't immediately fail
   the job.

2. ci.yml: add a post-check prune step (if: always()) so the engine
   cache is cleaned up after every run, reducing the chance of disk
   exhaustion on the next run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:52:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e7d61e8ee1 fix(ci): prune Dagger cache on disk-space error and before check
Previous fix (retry × 3 with 60 s sleep) was not enough: all three
attempts still failed because the engine cache stayed full throughout.
Add an explicit `dagger query '{ engine { localCache { prune } } }'`
call (a) as a proactive step in ci.yml right after the stunnel setup,
and (b) inside the retry handler before each back-off sleep (now 90 s
instead of 60 s). The prune evicts stale execution-cache snapshots
(e.g. old pubspec.lock layers) so fresh disk is available when flutter
pub get runs. The `|| true` guard makes the prune non-fatal if the
query syntax changes between Dagger versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:39:40 +02:00
Thomas SharedInbox 0e9d7c907e fix(ci): retry on disk-space errors in check-dagger
Dagger engine occasionally runs out of disk during `flutter pub get`
when multiple CI jobs run in parallel.  Space typically frees up within
~60 seconds as other containers finish.  Add "No space left on device"
as a retryable condition with a 60 s back-off so PR runs survive the
transient shortage (run 4199480 was the trigger).
2026-05-24 00:23:43 +02:00
Thomas SharedInbox ae70646ed4 fix: enable core library desugaring for flutter_local_notifications (#183)
Both isCoreLibraryDesugaringEnabled = true in compileOptions and the
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
dependency are already present in android/app/build.gradle.kts from
the earlier fix in #37. This commit closes issue #183 which was opened
to track the same requirement.
2026-05-24 00:13:23 +02:00
Bot of Thomas Güttler 4f6f1d9437 fix: migrate to Riverpod 3.x and update dependencies (#175) (#190) 2026-05-23 19:50:11 +02:00
Bot of Thomas Güttler 833e8d49b0 fix: remove continue-on-error from CI workflows (#172) (#189) 2026-05-23 19:05:08 +02:00
Bot of Thomas Güttler 6adba9b001 perf: parallelize APK deploy and reduce fetch-depth in deploy.yml (#171) (#188) 2026-05-23 18:55:08 +02:00
Bot of Thomas Güttler 11d9805fca test: cover _resolveDatabasePath retry logic (#167) (#187) 2026-05-23 18:35:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3019fdf145 refactor(deploy_cron): trigger Forgejo Actions workflow via fgj instead of deploying locally
Replace local `task publish-website` invocation with `fgj actions workflow run website.yml`
so the deploy runs in CI rather than on the local machine. Remove failure-tracking state
files and issue-creation logic — Forgejo Actions handles its own reporting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:42:20 +02:00
Bot of Thomas Güttler 14342f6472 fix: use exact grep patterns for build_runner and flutter pub get (#136) (#159) 2026-05-23 17:25:08 +02:00
Bot of Thomas Güttler b86c1a5c69 fix: verify Hugo binary SHA-256 checksum after download (#162) (#182) 2026-05-23 17:10:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 6e22683f5b fix(crash_screen): remove duplicate gitLine definition left by rebase conflict resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:02:39 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 dc181d0d85 fix: add git hash to crash screen and extend DB path retries (#179)
Two issues from #179:
- crash_screen.dart now reads GIT_HASH compile-time constant and includes
  'Git Commit: <hash>' in both the on-screen UI and the copied report, so
  crash reports always show the exact build that crashed.
- _resolveDatabasePath() retry delays extended from [100, 300, 600] ms
  (total ~1 s, 4 attempts) to [200, 500, 1000, 2000, 4000] ms (total
  ~7.7 s, 6 attempts) to handle slow/non-standard Android devices where
  the path_provider Pigeon channel takes several seconds to become ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:53:07 +02:00
Bot of Thomas Güttler e37d8066cb fix: prevent Gradle daemon hang in Android test build (#155) (#178) 2026-05-23 15:45:08 +02:00
Bot of Thomas Güttler 1b1f9788fd docs: explain why continue-on-error is intentional on deploy steps (#154) (#177) 2026-05-23 15:30:14 +02:00
Bot of Thomas Güttler 19d8d282ba fix: show UUID in agent-loop resume command (#152) (#176) 2026-05-23 15:20:08 +02:00
Bot of Thomas Güttler aa59dbb852 feat: show CI run link in 'CI passed' message (#151) (#174) 2026-05-23 15:05:07 +02:00
826488192d fix: update flutter packages (#148) (#165)
## Summary

- Upgrades 9 direct dependencies and their transitive peers to resolve the CI warning: *"38 packages have newer versions incompatible with dependency constraints"*
- Reduces incompatible-version count from **38 → 21** (the remaining 21 are either deliberately pinned, constrained by transitive dep ceilings, or require a separate riverpod 2→3 migration)
- Adapts two source files to breaking API changes in the upgraded packages:
  - `notification_service.dart`: `flutter_local_notifications` 21.x changed positional args to named params (`initialize(settings:…)`, `show(id:…, title:…, body:…, notificationDetails:…)`)
  - `compose_screen.dart`: `file_picker` 12.x removed `FilePicker.platform` static getter; calls are now `FilePicker.pickFiles()`

## Packages changed

| Package | Before | After |
|---|---|---|
| `go_router` | ^14.8.1 | ^17.2.3 |
| `flutter_local_notifications` | ^18.0.1 | ^21.0.0 |
| `file_picker` | ^8.0.0 | ^12.0.0-beta.4 |
| `mobile_scanner` | ^5.0.0 | ^7.2.0 |
| `package_info_plus` | ^8.0.0 | ^10.1.0 |
| `share_plus` | ^12.0.2 | ^13.1.0 |
| `sqlite3_flutter_libs` | ^0.5.28 | ^0.6.0+eol |
| `flutter_lints` | ^4.0.0 | ^6.0.0 |
| `flutter_secure_storage` | 10.2.0 | 10.3.0 (patch) |

## Test plan

- [x] `flutter analyze` — no issues
- [x] Unit tests (324 passed)
- [x] Widget tests (116 passed)
- [ ] CI full check suite

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/165
2026-05-23 14:58:54 +02:00
Bot of Thomas Güttler d683da9c59 docs: credential security options — four solutions for keeping production secrets off Codeberg (#141) (#173) 2026-05-23 14:50:12 +02:00
77fc6964f6 fix: extend path_provider retry budget on slow Android devices (#166) (#169)
## Summary

- Increases the retry delays in `_resolveDatabasePath()` from `[100, 300, 600]` ms (~1 s) to `[200, 500, 1000, 2000]` ms (~3.7 s).
- Adds a regression test (`test/unit/database_path_test.dart`) that verifies `initDatabasePath()` does not throw when the `path_provider` channel is unavailable.

## Root cause

On some slow Android devices (e.g. the Motorola reported in #166), the `path_provider` Pigeon channel is not ready even several seconds after `runApp()` returns. The previous back-off budget of ~1 s was not enough, causing `_resolveDatabasePath()` to exhaust all retries and throw a `PlatformException`, crashing the app with the message shown in the issue.

## Test plan

- [ ] `flutter test test/unit/database_path_test.dart` passes (new regression test)
- [ ] `flutter test test/unit/` — all 325 unit tests pass
- [ ] `flutter analyze` — no issues

Fixes #166

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/169
2026-05-23 14:40:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 47824c5711 Handle transient git fetch failures gracefully
Exit cleanly instead of crashing so the next cron run retries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:13:14 +02:00
Bot of Thomas Güttler a6c231f293 feat: show git commit link on crash screen (#150) (#170) 2026-05-23 13:45:08 +02:00
27 changed files with 628 additions and 301 deletions
+33
View File
@@ -30,11 +30,44 @@ 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"
run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || 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 } } }' 2>/dev/null || true
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+41 -22
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -31,6 +31,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 }}
@@ -49,7 +50,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -66,6 +67,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,12 +75,36 @@ 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
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: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but we don't want that to fail the whole job — the Play
# Store publish above already succeeded. The overall job stays green even
# though this step shows as failed/orange in the UI.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -100,7 +126,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -117,12 +143,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but the build step that precedes this (done via Dagger)
# already succeeded. Deployment is best-effort; a missing secret should not
# turn the job red. The step will show as failed/orange in the UI even though
# the overall job is green — this is intentional.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -137,16 +158,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: |
@@ -163,9 +184,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
# should not block the overall workflow status.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -180,7 +199,7 @@ jobs:
label-deploy-health:
name: Update Deploy Health Label
runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, build-linux]
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5
@@ -190,7 +209,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.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
-3
View File
@@ -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 }}
+6 -1
View File
@@ -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 } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else
return "$RC"
fi
+4 -3
View File
@@ -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)
}
+14 -106
View File
@@ -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__':
+5 -5
View File
@@ -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,
+16 -15
View File
@@ -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).
+4
View File
@@ -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();
+53 -3
View File
@@ -596,9 +596,9 @@ 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 exponential back-off.
// Longer delays handle slow/non-standard Android devices where the Pigeon
// channel takes several seconds to become available.
// 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 {
@@ -609,6 +609,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 — '
@@ -616,6 +627,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());
+11 -12
View File
@@ -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);
}
}
+1
View File
@@ -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 -1
View File
@@ -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;
+29 -1
View File
@@ -23,9 +23,11 @@ class CrashScreen extends StatelessWidget {
final info = await PackageInfo.fromPlatform();
version = '${info.version}+${info.buildNumber}';
} catch (_) {}
final gitLine = _gitHash.isNotEmpty ? 'Git Commit: $_gitHash\n' : '';
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'
@@ -104,6 +106,32 @@ class CrashScreen extends StatelessWidget {
),
),
],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.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;
+3 -3
View File
@@ -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();
}
+60 -44
View File
@@ -313,6 +313,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 +333,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 +359,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 +415,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 +463,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 +502,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 +603,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 +659,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 +715,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 +891,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 +992,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 +1096,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 +1120,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 +1280,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "6.3.0"
workmanager:
dependency: "direct main"
description:
+9 -9
View File
@@ -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,19 @@ 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
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
+25 -4
View File
@@ -170,11 +170,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():
@@ -511,12 +511,33 @@ 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}.")
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
_merge_pr(pr_number)
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
# 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
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
run = _latest_ci_run()
+35 -7
View File
@@ -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
+64 -19
View File
@@ -256,22 +256,35 @@ 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_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": 4145144, "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": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
@@ -280,24 +293,51 @@ class TestPendingCi(unittest.TestCase):
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
def test_ci_passed_output_without_run_omits_ci_url(self):
"""'CI passed' line still works when no CI run is available."""
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._latest_ci_run", return_value=None), \
patch("agent_loop._close_issue"), \
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):
agent_loop._run_loop()
result = agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("CI passed", output)
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
self.assertIn("already merged", output)
self.assertNotIn("/actions/runs/", output)
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_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._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
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"), \
@@ -308,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):
@@ -317,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()
@@ -328,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):
@@ -337,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"):
@@ -348,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):
+156
View File
@@ -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);
}
},
);
}
+1
View File
@@ -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';
+39 -35
View File
@@ -78,46 +78,50 @@ void main() {
});
testWidgets(
'Copy to Clipboard includes App Version and excludes Git Commit when GIT_HASH is empty',
(
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
'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());
UrlLauncherPlatform.instance = MockUrlLauncher();
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),
);
String? capturedClipboard;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(MethodCall call) async {
if (call.method == 'Clipboard.setData') {
capturedClipboard = (call.arguments as Map)['text'] as String;
}
return null;
},
);
const exception = 'TestException: clipboard test';
final stackTrace = StackTrace.current;
const exception = 'TestException: clipboard test';
final stackTrace = StackTrace.current;
await tester.pumpWidget(
MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace),
),
);
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();
await tester.tap(find.text('Copy to Clipboard'));
await tester.pumpAndSettle();
expect(capturedClipboard, isNotNull);
expect(capturedClipboard, contains('App Version:'));
expect(capturedClipboard, contains('Platform:'));
expect(capturedClipboard, contains('TestException: clipboard test'));
// GIT_HASH is empty in tests (no --dart-define), so the line is omitted.
expect(capturedClipboard, isNot(contains('Git Commit:')));
});
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 used as root widget — buttons work without ScaffoldMessenger crash',
+1 -1
View File
@@ -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';
+13 -3
View File
@@ -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(),