Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 f7e0ffd4d5 docs: explain why continue-on-error is intentional on deploy steps (#154)
The deploy steps that require SSH_PRIVATE_KEY are best-effort: if the
secret is not set the task precondition fails and the step appears
failed/orange in the UI, but the overall job remains green because of
continue-on-error: true.  This confused issue #154.  Add inline
comments to each affected step explaining that this behavior is
intentional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:27:52 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b28d3f6787 fix(agent_loop): show UUID in resume command (#152)
The resume command previously showed the session *name* (e.g.
`claude --resume issue-146`), which does not work because
`claude --resume` requires the session UUID, not its name.

Changes:
- Add `_find_session_uuid(session_name)` that scans Claude's JSONL
  files to look up the UUID for a named session.
- `_run_loop()` now resolves the UUID and prints
  `claude --resume <uuid>` when an agent is running; falls back to
  a hint to run `scripts/agent_loop.py list` if the UUID cannot be
  found yet.
- `_start_agent()` no longer prints a broken resume command (the UUID
  is not available immediately at startup); it now directs the user
  to `scripts/agent_loop.py list`.
- Module docstring updated to document the UUID-based resume workflow.
- 13 new tests covering `_find_session_uuid()` and the updated resume
  output in `_run_loop()`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:16:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 5b48b55624 feat(agent_loop): show CI run URL in 'CI passed' message (#151)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:57:28 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7414f36712 docs: document four options for keeping production secrets off Codeberg (#141)
Add a "Credential Security" section to DAGGER.md that explains the
current problem (production secrets stored in Codeberg alongside Dagger
TLS credentials) and lists four solutions with pros/cons:

1. Runner-level environment variables — simplest, no new infra
2. Secret files on CI host with restricted permissions — OS-enforced isolation
3. Dagger host as pipeline orchestrator — cleanest security boundary
4. External secret manager (Vault) — full audit trail, team-scale solution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:41:16 +02:00
30 changed files with 349 additions and 917 deletions
-37
View File
@@ -30,48 +30,11 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Locate Docker daemon for local Dagger engine
run: |
# Skip if remote Dagger engine is already configured (preferred path)
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
echo "Remote Dagger engine configured, no local Docker needed."
exit 0
fi
# Try host Docker socket (DooD) if runner mounts it
if [ -S /var/run/docker.sock ]; then
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
echo "Docker available via host socket."
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
echo "CI will likely fail at the Dagger step." >&2
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger
- name: Prune Dagger cache after check
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+22 -41
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
@@ -31,7 +31,6 @@ 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 }}
@@ -50,7 +49,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
@@ -67,7 +66,6 @@ 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 }}
@@ -75,36 +73,12 @@ 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
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but we don't want that to fail the whole job — the Play
# Store publish above already succeeded. The overall job stays green even
# though this step shows as failed/orange in the UI.
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -126,7 +100,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
@@ -143,7 +117,12 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but the build step that precedes this (done via Dagger)
# already succeeded. Deployment is best-effort; a missing secret should not
# turn the job red. The step will show as failed/orange in the UI even though
# the overall job is green — this is intentional.
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -158,16 +137,16 @@ jobs:
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
needs: [build-linux, deploy-playstore]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 50
- name: Check runner tools
run: |
@@ -184,7 +163,9 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
# should not block the overall workflow status.
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -199,7 +180,7 @@ jobs:
label-deploy-health:
name: Update Deploy Health Label
runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
needs: [test-android-firebase, deploy-playstore, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5
@@ -209,7 +190,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
+3
View File
@@ -11,6 +11,7 @@ jobs:
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
if: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -31,6 +32,7 @@ jobs:
- name: Set up SSH key
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
@@ -40,6 +42,7 @@ jobs:
- name: Deploy Windows to server
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
+1 -12
View File
@@ -284,13 +284,8 @@ tasks:
for attempt in 1 2 3; do
run_dagger "$@" && return 0
RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else
return "$RC"
fi
@@ -320,12 +315,6 @@ tasks:
wait "$RECV_PID" 2>/dev/null || true
exit $RC
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup]
+5 -9
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 '^(\+|Downloading packages)' "$tmp" || true`}).
`grep -vE '^[+~><] ' "$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 '^\[.*s\] \|' "$tmp" || true`})
`grep -vE '^\[' "$tmp" || true`})
}
// setup overlays platform-specific source files onto the shared codegen base.
@@ -312,7 +312,6 @@ 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"})
}
@@ -411,7 +410,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 '^\[.*s\] \|' "$tmp" || true`}).
`grep -vE '^\[' "$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)
}
@@ -650,12 +649,9 @@ 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").
// --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"}).
WithExec([]string{"./gradlew", "app:assembleAndroidTest"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c",
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
@@ -739,7 +735,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "google-api-python-client", "google-auth-httplib2", "httplib2"}).
WithExec([]string{"pip", "install", "requests", "google-auth"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
+106 -14
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env python3
"""
Cron deploy script for sharedinbox website.
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.
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.
"""
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'
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
REPO = 'guettli/sharedinbox'
CODEBERG = 'https://codeberg.org'
def git(*args):
@@ -25,30 +32,115 @@ def read(path: Path) -> str:
return path.read_text().strip() if path.exists() else ''
def main():
def read_int(path: Path) -> int:
try:
git('fetch', 'origin', 'main')
except subprocess.CalledProcessError as exc:
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
return
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')
remote_sha = git('rev-parse', 'origin/main')
last_sha = read(SHA_FILE)
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)
if remote_sha == last_sha:
print(f'No changes since {remote_sha[:8]}, skipping.')
return
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
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')
result = subprocess.run(
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
['task', 'publish-website'],
cwd=REPO_DIR,
capture_output=True, text=True,
)
combined = result.stdout + result.stderr
print(combined, end='')
if result.returncode != 0:
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
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)
sys.exit(1)
SHA_FILE.write_text(remote_sha + '\n')
print('Workflow triggered.')
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
f.unlink(missing_ok=True)
print('Deploy complete.')
if __name__ == '__main__':
+2 -3
View File
@@ -94,9 +94,8 @@
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
google-auth
requests
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]);
+5 -5
View File
@@ -13,7 +13,7 @@ Future<void> initNotifications() async {
try {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
settings: const InitializationSettings(android: android),
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(
id: accountEmail.hashCode & 0x7FFFFFFF,
title: 'New mail',
body: accountEmail,
notificationDetails: const NotificationDetails(
accountEmail.hashCode & 0x7FFFFFFF,
'New mail',
accountEmail,
const NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
+15 -16
View File
@@ -4,39 +4,38 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
class UndoService extends Notifier<List<UndoAction>> {
class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
static const int _maxHistory = 10;
// Resolves once build() has loaded persisted history.
late Future<void> _ready;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
@override
List<UndoAction> build() {
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
if (ref.mounted) state = history;
Future<void> init() async {
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
});
return [];
await _ready;
}
/// Waits for the persisted history to finish loading. Called by tests to
/// ensure the provider is ready before asserting state.
Future<void> init() => _ready;
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
}
state = newList;
await ref.read(undoRepositoryProvider).saveAction(action);
await _ref.read(undoRepositoryProvider).saveAction(action);
}
Future<void> clear() async {
await _ready;
state = [];
unawaited(ref.read(undoRepositoryProvider).clearHistory());
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
@@ -58,7 +57,7 @@ class UndoService extends Notifier<List<UndoAction>> {
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
// the local change). The inverse action added below allows undoing the undo.
final repo = ref.read(emailRepositoryProvider);
final repo = _ref.read(emailRepositoryProvider);
for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet).
-4
View File
@@ -6,7 +6,6 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -25,9 +24,6 @@ const _kResourceType = 'background_check';
@pragma('vm:entry-point')
void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((_, __) async {
try {
await _doBackgroundSync();
+2 -54
View File
@@ -596,10 +596,8 @@ 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 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];
// that the engine is fully initialised, with brief back-off.
const delays = [100, 300, 600];
for (final ms in delays) {
try {
final dir = await getApplicationSupportDirectory();
@@ -609,17 +607,6 @@ Future<String> _resolveDatabasePath() async {
await Future<void>.delayed(Duration(milliseconds: ms));
}
}
// On Android, path_provider can be permanently broken on some devices
// regardless of how long we wait (issue #192). Derive the path from
// /proc/self/cmdline (the Android process name == package name) without
// a platform channel as a last resort so the app can still open its DB.
if (Platform.isAndroid) {
final fallback = await _androidFallbackPath();
if (fallback != null) {
_dbPath = fallback;
return _dbPath!;
}
}
throw PlatformException(
code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
@@ -627,45 +614,6 @@ 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());
+12 -11
View File
@@ -11,7 +11,6 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -102,7 +101,7 @@ final searchHistoryRepositoryProvider =
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -182,7 +181,11 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
});
final undoServiceProvider =
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
final service = UndoService(ref);
unawaited(service.init());
return service;
});
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -191,18 +194,16 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new,
);
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
@override
Future<(Email?, EmailBody)> build() async {
Future<(Email?, EmailBody)> build(String emailId) async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(_emailId),
repo.getEmailBody(_emailId),
repo.getEmail(emailId),
repo.getEmailBody(emailId),
]);
unawaited(repo.setFlag(_emailId, seen: true));
unawaited(repo.setFlag(emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody);
}
}
-1
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
+1 -1
View File
@@ -162,7 +162,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
}
Future<void> _pickAttachments() async {
final result = await FilePicker.pickFiles();
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return;
final files = result.files.where((f) => f.path != null).toList();
if (!mounted) return;
-40
View File
@@ -15,8 +15,6 @@ class CrashScreen extends StatelessWidget {
final Object exception;
final StackTrace? stackTrace;
static const _gitHash = String.fromEnvironment('GIT_HASH');
Future<String> _buildReport() async {
String version = 'unknown';
try {
@@ -25,11 +23,7 @@ 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```';
@@ -56,14 +50,6 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'Git Commit: $_gitHash',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
const Text(
'Error Details:',
@@ -106,32 +92,6 @@ class CrashScreen extends StatelessWidget {
),
),
],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.value?.$1;
final email = next.valueOrNull?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
},
);
final header = detail.value?.$1;
final body = detail.value?.$2;
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
+3 -3
View File
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.value;
final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
+44 -60
View File
@@ -313,14 +313,6 @@ 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:
@@ -333,10 +325,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "12.0.0-beta.4"
version: "8.3.7"
fixnum:
dependency: transitive
description:
@@ -359,42 +351,34 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "4.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "21.0.0"
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
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"
version: "8.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
@@ -415,34 +399,34 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
version: "10.2.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
version: "0.3.1"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@@ -463,10 +447,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -502,10 +486,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "17.2.3"
version: "14.8.1"
graphs:
dependency: transitive
description:
@@ -603,10 +587,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "4.0.0"
logging:
dependency: transitive
description:
@@ -659,10 +643,10 @@ packages:
dependency: "direct main"
description:
name: mobile_scanner
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
url: "https://pub.dev"
source: hosted
version: "7.2.0"
version: "5.2.3"
mockito:
dependency: "direct dev"
description:
@@ -715,18 +699,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "10.1.0"
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "3.2.1"
path:
dependency: "direct main"
description:
@@ -891,26 +875,26 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "2.6.1"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
version: "12.0.2"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
version: "6.1.0"
shelf:
dependency: transitive
description:
@@ -992,10 +976,10 @@ packages:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.6.0+eol"
version: "0.5.42"
sqlparser:
dependency: transitive
description:
@@ -1096,10 +1080,10 @@ packages:
dependency: transitive
description:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.11.0"
version: "0.10.1"
typed_data:
dependency: transitive
description:
@@ -1120,10 +1104,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
@@ -1280,10 +1264,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "5.15.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.6.0+eol
sqlite3_flutter_libs: ^0.5.28
path_provider: ^2.1.5
path: ^1.9.1
# State management
flutter_riverpod: ^3.0.0
flutter_riverpod: ^2.6.1
# Navigation
go_router: ^17.2.3
go_router: ^14.8.1
# 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: ^12.0.0-beta.4
file_picker: ^8.0.0
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: ^7.2.0
mobile_scanner: ^5.0.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: ^21.0.0
flutter_local_notifications: ^18.0.1
workmanager: ^0.9.0
# App version metadata for crash reports
package_info_plus: ^10.1.0
share_plus: ^13.1.0
package_info_plus: ^8.0.0
share_plus: ^12.0.2
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_lints: ^4.0.0
drift_dev: ^2.20.3
build_runner: ^2.4.13
test: ^1.25.0
+4 -25
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, state: str = "open") -> dict | None:
"""Return the first PR in the given state whose head branch matches, or None."""
def _find_pr_for_branch(branch: str) -> dict | None:
"""Return the first open PR whose head branch matches, or None."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", state, "--json"],
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
@@ -511,33 +511,12 @@ def _run_loop() -> int:
return 0
# CI passed on the PR branch — squash-merge and close.
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
print(f"CI passed 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()
+83 -54
View File
@@ -6,17 +6,76 @@ import os
import sys
import time
import google_auth_httplib2
import httplib2
import requests
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
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"
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"],
)
return AuthorizedSession(creds)
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
"""Resumable upload of the AAB. Returns the version code."""
file_size = os.path.getsize(AAB_PATH)
with open(AAB_PATH, "rb") as f:
data = f.read()
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
init_resp = session.post(
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
},
json={},
timeout=30,
)
if not init_resp.ok:
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
upload_resp = session.put(
upload_url,
data=data,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=_TIMEOUT,
)
if not upload_resp.ok:
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
upload_resp.raise_for_status()
return upload_resp.json()["versionCode"]
except requests.RequestException as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
time.sleep(delay)
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
def main():
@@ -29,61 +88,31 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
sys.exit(1)
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
session = _make_session(config_json)
edit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
authorized_http = google_auth_httplib2.AuthorizedHttp(
creds, http=httplib2.Http(timeout=_TIMEOUT)
)
service = build("androidpublisher", "v3", http=authorized_http)
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
edit_id = edit["id"]
# The resumable upload can fail with RedirectMissingLocation on transient
# network hiccups. Retry with a fresh MediaFileUpload each time (resumable
# uploads can't reuse the same object) using exponential backoff.
version_code = None
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
media = MediaFileUpload(
AAB_PATH, mimetype="application/octet-stream", resumable=True
)
bundle = (
service.edits()
.bundles()
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
.execute(num_retries=3)
)
version_code = bundle["versionCode"]
break
except httplib2.error.RedirectMissingLocation as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed (redirect error), "
f"retrying in {delay}s…"
)
time.sleep(delay)
else:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = _upload_aab(session, edit_id)
print(f"Uploaded AAB, version code: {version_code}")
service.edits().tracks().update(
packageName=PACKAGE_NAME,
editId=edit_id,
track=TRACK,
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
).execute(num_retries=3)
tracks_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()
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute(num_retries=3)
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
timeout=30,
)
commit_resp.raise_for_status()
print(f"Deployed version {version_code} to {TRACK} track")
+6 -34
View File
@@ -14,42 +14,14 @@ if [ "$host" == "$port" ]; then
port="8774"
fi
MAX_PROBE_ATTEMPTS=5
PROBE_DELAY=30
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active server on $host:$port"
break
fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0
fi
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
sleep $PROBE_DELAY
done
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
timeout 8 dagger version >/dev/null 2>&1; then
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
echo "Dagger configured at tcp://$host:$port (plain TCP)"
fi
exit 0
echo "Probing $host:$port..."
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
echo "Error: No Dagger server responded on $host:$port"
exit 1
fi
echo "Plain TCP connection not available; trying TLS stunnel..."
echo "Found active Dagger server on $host:$port"
# 2b. Setup TLS credentials (passed as env vars from secrets)
# 2. Setup TLS credentials (passed as env vars from secrets)
mkdir -p /tmp/dagger-tls
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
+18 -63
View File
@@ -256,35 +256,22 @@ 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 merges the PR and closes the issue once CI is green."""
"""After issue agent finishes, loop closes the issue once CI is green."""
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": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
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._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._latest_ci_run", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._close_issue"), \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
@@ -293,51 +280,24 @@ 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_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
def test_ci_passed_output_without_run_omits_ci_url(self):
"""'CI passed' line still works when no CI run is available."""
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._close_issue"), \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
result = agent_loop._run_loop()
agent_loop._run_loop()
output = buf.getvalue()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
self.assertIn("already merged", output)
self.assertIn("CI passed", output)
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", 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."""
"""After issue agent finishes, loop must NOT close the issue if CI failed."""
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._latest_ci_run", 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"), \
@@ -348,7 +308,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 on PR branch after agent finishes, pending issue is preserved."""
"""When CI is still running 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):
@@ -357,8 +317,7 @@ class TestPendingCi(unittest.TestCase):
written["kind"] = kind
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": "running"}), \
patch("agent_loop._latest_ci_run", 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()
@@ -369,7 +328,7 @@ class TestPendingCi(unittest.TestCase):
self.assertIsNone(written.get("pid"))
def test_ci_fix_preserves_pending_issue_in_state(self):
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
"""When CI fails 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):
@@ -378,8 +337,7 @@ class TestPendingCi(unittest.TestCase):
written["kind"] = kind
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._latest_ci_run", 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"):
@@ -390,17 +348,14 @@ 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 on PR branch, the pending issue is closed."""
"""After ci-fix agent finishes and CI passes, the pending issue is closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
patch("agent_loop._find_pr_for_branch", side_effect=self._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._latest_ci_run", return_value={"id": 1, "status": "success"}), \
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):
-199
View File
@@ -1,199 +0,0 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import io
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
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):
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "edit-42"}
mock_edits.bundles.return_value.upload.return_value.execute.return_value = {
"versionCode": 7
}
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
deploy_playstore.main()
return mock_edits
def test_insert_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.insert.return_value.execute.assert_called_once_with(num_retries=3)
def test_bundle_upload_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(num_retries=3)
def test_tracks_update_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.tracks.return_value.update.return_value.execute.assert_called_once_with(num_retries=3)
def test_commit_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.commit.return_value.execute.assert_called_once_with(num_retries=3)
def test_authorized_http_uses_timeout(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=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.httplib2.Http") as mock_http_cls:
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp") as mock_auth:
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "e1"}
mock_edits.bundles.return_value.upload.return_value.execute.return_value = {
"versionCode": 1
}
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
deploy_playstore.main()
mock_http_cls.assert_called_once_with(timeout=deploy_playstore._TIMEOUT)
def _redirect_error():
import httplib2
return httplib2.error.RedirectMissingLocation("redirect missing", {}, b"")
class TestUploadRetry(unittest.TestCase):
def _make_mock_service(self, upload_side_effects):
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "edit-1"}
mock_edits.bundles.return_value.upload.return_value.execute.side_effect = (
upload_side_effects
)
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
return mock_service, mock_edits
def _run_with_service(self, mock_service):
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=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep"):
deploy_playstore.main()
def test_succeeds_on_first_attempt(self):
mock_service, mock_edits = self._make_mock_service([{"versionCode": 5}])
self._run_with_service(mock_service)
mock_edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(
num_retries=3
)
def test_retries_once_on_redirect_error_then_succeeds(self):
mock_service, mock_edits = self._make_mock_service(
[_redirect_error(), {"versionCode": 9}]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload") as mock_media_cls:
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
self.assertEqual(
mock_edits.bundles.return_value.upload.return_value.execute.call_count, 2
)
mock_sleep.assert_called_once_with(10)
self.assertEqual(mock_media_cls.call_count, 2)
def test_raises_after_all_attempts_exhausted(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), _redirect_error(), _redirect_error()]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep"):
with self.assertRaises(RuntimeError) as ctx:
deploy_playstore.main()
self.assertIn(
str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception)
)
def test_backoff_delays_are_10s_then_20s(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), _redirect_error(), {"versionCode": 3}]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
def test_fresh_media_upload_created_on_each_attempt(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), {"versionCode": 2}]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload") as mock_media_cls:
with patch("deploy_playstore.time.sleep"):
deploy_playstore.main()
self.assertEqual(mock_media_cls.call_count, 2)
if __name__ == "__main__":
unittest.main()
-156
View File
@@ -1,156 +0,0 @@
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,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
-47
View File
@@ -1,5 +1,4 @@
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';
@@ -77,52 +76,6 @@ 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 used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
+3 -13
View File
@@ -6,7 +6,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
@@ -20,7 +19,6 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -475,18 +473,10 @@ Widget buildApp({
);
return ProviderScope(
// Defaults come first so tests can override them via [overrides].
//
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
// framework then fails the test with "A Timer is still pending". Replacing
// these with simple synchronous streams avoids the pending-timer assertion.
// Always neutralise the ManageSieve probe so widget tests never open a
// real socket. Tests that need to assert on probe behaviour should supply
// their own override before this default in [overrides].
overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),