Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 a1b9e0a8b0 feat: show app version as link on crash screen and in MD report (#236)
When a git hash is available, the crash screen now displays the app
version number as a tappable link (pointing to the Codeberg commit
page) above the existing git-hash link, and the clipboard markdown
report formats the App Version line as a markdown link in the same way
the About screen already does.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:12:35 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3a08daa402 style: format edit_account_screen_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:58:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2336afa0d7 fix: show password required error instead of crashing when no stored password (#235)
During _load(), check whether a password exists in secure storage and track the result
in _hasStoredPassword. The password field validator now requires user input when no
password is stored, so _tryConnection() fails fast at form validation instead of
throwing an unhandled StateError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:47:51 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c343ed6bd7 feat: monitor agent loop health every 2 hours (#217)
- Track a heartbeat timestamp in ~/.sharedinbox-agent-heartbeat at the
  start of each _run_loop() invocation so we can tell when it last ran.
- Add `agent_loop.py monitor` subcommand that exits 1 with a WARNING
  message if the heartbeat is missing, corrupted, or older than 2 hours.
- Add .forgejo/workflows/monitor.yml scheduled workflow that runs the
  monitor check every 2 hours on the self-hosted runner; a CI failure
  serves as the warning when the loop is stalled.
- Add 7 unit tests covering all monitor / heartbeat scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:27:03 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1d5eb187bf fix: fall back to text input when mobile_scanner plugin is unavailable (#202)
On some Android builds the mobile_scanner native plugin is not registered,
causing a MissingPluginException when the send/receive screens try to open
the QR scanner.  Add a pre-flight _initScanner() method that starts and
immediately stops a temporary MobileScannerController in a try/catch; any
exception (including MissingPluginException) sets _scannerFailed=true and
the UI falls back to the existing copy-paste text-input flow instead of
leaving the user stuck with a blank camera view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:47:15 +02:00
116 changed files with 3375 additions and 4532 deletions
+6
View File
@@ -6,6 +6,12 @@
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml # ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
FROM ghcr.io/catthehacker/ubuntu:go-24.04 FROM ghcr.io/catthehacker/ubuntu:go-24.04
# Infrastructure tools required by CI workflows
RUN apt-get update && apt-get install -y --no-install-recommends \
stunnel4 \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# Dagger CLI — pinned to match the engine version on the runner host # Dagger CLI — pinned to match the engine version on the runner host
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \ RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh | DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
+100 -3
View File
@@ -1,14 +1,111 @@
name: CI name: CI
on: [push, pull_request]
on:
push:
branches: [main]
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
pull_request:
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
jobs: jobs:
check: check:
name: Full Project Check name: Full Project Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Dagger Remote Engine with:
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
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: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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 - name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger 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
+135 -58
View File
@@ -17,13 +17,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 2
- name: Detect Android and Linux changes - name: Detect Android and Linux changes
id: diff id: diff
shell: bash shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: | run: |
# On workflow_dispatch always build everything # On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
@@ -32,54 +30,15 @@ jobs:
exit 0 exit 0
fi fi
HEAD_SHA=$(git rev-parse HEAD) # Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
# Skip if this exact commit was already successfully deployed (prevents CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
# hourly schedule from redeploying the same commit on every tick). || git show --name-only --format= HEAD)
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("status") == "success"
]
print(runs[0].get("commit_sha") or "")
except Exception as e:
print(f"API check failed: {e}", file=sys.stderr)
print("")
PYEOF
)
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff from the last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
# LAST_DEPLOYED_SHA is unknown or not in local history.
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
fi
echo "Changed files:" echo "Changed files:"
echo "$CHANGED" echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)' android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)' linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \ echo "$CHANGED" | grep -qE "$android_re" \
@@ -90,6 +49,44 @@ jobs:
&& echo "linux=true" >> "$GITHUB_OUTPUT" \ && echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT" || echo "linux=false" >> "$GITHUB_OUTPUT"
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
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: 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 }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-playstore: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -100,23 +97,34 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 100 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | 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 dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store - name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-android 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: deploy-apk:
name: Build & Deploy APK to Server name: Build & Deploy APK to Server
@@ -128,23 +136,37 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 100 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | 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 dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server - name: Build & Deploy APK to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-apk run: task deploy-apk
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
build-linux: build-linux:
name: Build Linux Release name: Build Linux Release
@@ -156,30 +178,85 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 100 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | 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 dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server - name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-linux run: task deploy-linux
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(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: 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: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [deploy-playstore, deploy-apk, build-linux] needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
if: | if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && ( always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' || needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' || needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure' needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
@@ -192,7 +269,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }} FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }} FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }} DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }} ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
run: | run: |
python3 - << 'PYEOF' python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error import os, json, urllib.request, urllib.error
-122
View File
@@ -1,122 +0,0 @@
name: Firebase Tests
on:
schedule:
- cron: '0 3 * * *' # once per day at 3 AM
workflow_dispatch:
jobs:
check-changes:
name: Detect Firebase-Relevant Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Firebase-relevant changes in last 24 hours
id: diff
shell: bash
run: |
# On workflow_dispatch always run
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
| sort -u | grep -v '^$')
if [ -n "$CHANGED" ]; then
echo "Firebase-relevant files changed since $SINCE:"
echo "$CHANGED"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
env:
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Create issue on test failure
if: failure()
env:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ["FORGEJO_URL"].rstrip("/")
run_url = os.environ["RUN_URL"]
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
def api_get(path):
req = urllib.request.Request(f"{api}{path}", headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def api_post(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
"Firebase instrumented tests failed in the daily run.\n\n"
f"**Failed run:** {run_url}\n\n"
"## Steps to resolve\n\n"
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
"3. Close this issue once the root cause is resolved and the tests pass.\n"
)
issue = api_post("/issues", {
"title": title,
"body": body,
"labels": label_ids,
})
print(f"Created issue #{issue['number']}: {issue['html_url']}")
PYEOF
+18
View File
@@ -0,0 +1,18 @@
name: Monitor Agent Loop
on:
schedule:
- cron: '0 */2 * * *' # every 2 hours
workflow_dispatch:
jobs:
monitor:
name: Check Agent Loop Health
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check agent loop heartbeat
run: python3 scripts/agent_loop.py monitor
-30
View File
@@ -1,30 +0,0 @@
name: Renovate
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- 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; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Renovate
env:
DAGGER_NO_NAG: "1"
run: task renovate
+8 -19
View File
@@ -1,8 +1,6 @@
name: Update Website name: Deploy Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -13,31 +11,22 @@ on:
jobs: jobs:
deploy: deploy:
name: Build & Update Website name: Build & Deploy Website
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Check runner tools - name: Build & Deploy Website
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; }
- name: Setup Dagger Remote Engine
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: scripts/setup_dagger_remote.sh SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
- name: Build & Update Website run: task website-deploy
env:
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Verify Website - name: Verify Website
env: env:
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }} SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh run: scripts/website-verify.sh
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.44.0" "flutter": "3.41.6"
} }
+1 -2
View File
@@ -28,8 +28,7 @@ android/.gradle/
android/local.properties android/local.properties
android/app/google-services.json android/app/google-services.json
android/key.properties android/key.properties
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that android/app/src/main/java/io/flutter/plugins/
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
.android/ .android/
Android/ Android/
.gradle/ .gradle/
+2 -2
View File
@@ -33,12 +33,12 @@ repos:
- id: ci-no-direct-dagger - id: ci-no-direct-dagger
name: check for direct dagger calls in workflows (use Task instead) name: check for direct dagger calls in workflows (use Task instead)
language: system language: system
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'" entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: dagger-progress-plain - id: dagger-progress-plain
name: ensure all dagger calls use --progress=plain name: ensure all dagger calls use --progress=plain
language: system language: system
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'" entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
+20 -29
View File
@@ -8,41 +8,32 @@ CLI tool `fgj` is available to query issues/PRs/actions.
## Issue Label Workflow ## Issue Label Workflow
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent: We use issues, follow this label state machine:
| Label | Trigger | Outcome | - **State/Ready** — Issue is available to pick up
|---|---|---| - **State/InProgress** — Set this when you start working on an issue
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` | - **State/Question** — Set this when you hit a blocker or need clarification
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
**State machine:** List open issues ready to pick up:
``` ```bash
loop/plan → loop/plan-in-progress → loop/plan-done fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-progress → loop/code-done
↘ NeedSupervisor (on failure)
``` ```
**Rules:** Rules:
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions). - Never start work on an issue without `State/Ready`
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label. - When working via the agent loop: `State/Ready``State/InProgress` is set automatically
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging. by `agent_loop.py` before the agent starts — do **not** set it yourself.
- Planning agents only post a comment — they do NOT write code or open PRs. - When working manually: switch to `State/InProgress` as your **first action**:
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active. ```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
**Typical lifecycle for a new feature:** ```
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
``` - When done and CI is green, close the issue:
1. Create issue ```bash
2. Add label loop/plan → agent writes plan as comment fgj issue close <NUMBER>
3. Review plan, request changes or approve ```
4. Add label loop/code → agent implements + opens PR
5. Review PR, merge
6. Close issue
```
## Code conventions ## Code conventions
-2
View File
@@ -188,5 +188,3 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow ## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands. Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
-5
View File
@@ -216,8 +216,3 @@ test/
- **Settings** — list and remove accounts - **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change - **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send - **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
+5 -23
View File
@@ -224,7 +224,7 @@ tasks:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds: cmds:
- mkdir -p build/app/outputs/bundle/release - mkdir -p build/app/outputs/bundle/release
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab - dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle: upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger desc: Upload AAB from build/ to Play Store via Dagger
@@ -238,7 +238,6 @@ tasks:
publish-android: publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
deps: [generate-changelog]
preconditions: preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON" - sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set" msg: "PLAY_STORE_CONFIG_JSON is not set"
@@ -247,7 +246,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH" - dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
deploy-apk: deploy-apk:
desc: Build and deploy Android APK via Dagger desc: Build and deploy Android APK via Dagger
@@ -294,11 +293,11 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; 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 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 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 echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2 echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90 sleep 90
else else
@@ -319,16 +318,7 @@ tasks:
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE" rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
} }
trap cleanup EXIT trap cleanup EXIT
until [ -s "$PORTFILE" ]; do until [ -s "$PORTFILE" ]; do sleep 0.05; done
sleep 0.05
if ! kill -0 "$RECV_PID" 2>/dev/null; then
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
retry_dagger dagger call --progress=plain -q -m ci --source=. check
RC=$?
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
exit $RC
fi
done
PORT=$(cat "$PORTFILE") PORT=$(cat "$PORTFILE")
retry_dagger env \ retry_dagger env \
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \ OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
@@ -345,14 +335,6 @@ tasks:
- | - |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
renovate:
desc: Run Renovate bot against the repository via Dagger
preconditions:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android: integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2) 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] deps: [_preflight, _android-sdk-check, _android-avd-setup]
+1
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
+3 -5
View File
@@ -16,10 +16,8 @@ android {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlin { kotlinOptions {
compilerOptions { jvmTarget = JavaVersion.VERSION_17.toString()
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
signingConfigs { signingConfigs {
@@ -69,7 +67,7 @@ flutter {
dependencies { dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26. // Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
// integration_test is a dev dependency; the Flutter plugin loader adds it as // integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main) // debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation // references its class in all variants. Make it available for release compilation
@@ -1,89 +0,0 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")
+4 -4
View File
@@ -44,10 +44,10 @@ require (
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0 replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0 replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
+16 -85
View File
@@ -181,7 +181,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container { func (m *Ci) toolchain() *dagger.Container {
return dag.Container(). return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.44.0"). From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
@@ -195,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
WithUser("ci"). WithUser("ci").
WithExec([]string{"/bin/sh", "-c", WithExec([]string{"/bin/sh", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}). `yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
} }
// Base is the Flutter toolchain container with mutable cache mounts attached. // Base is the Flutter toolchain container with mutable cache mounts attached.
@@ -286,21 +285,6 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
}) })
} }
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func (m *Ci) androidBase() *dagger.Container {
return m.setup(m.androidSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func (m *Ci) firebaseBase() *dagger.Container {
return m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// linuxSrc is the source subset for Linux builds and integration tests. // linuxSrc is the source subset for Linux builds and integration tests.
func (m *Ci) linuxSrc() *dagger.Directory { func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{ return m.Source.Filter(dagger.DirectoryFilterOpts{
@@ -599,17 +583,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
} }
// BuildLinuxRelease builds the Linux release bundle. // BuildLinuxRelease builds the Linux release bundle.
func (m *Ci) BuildLinuxRelease( func (m *Ci) BuildLinuxRelease() *dagger.Directory {
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.Directory {
args := []string{"flutter", "build", "linux", "--release"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setup(m.linuxSrc()). return m.setup(m.linuxSrc()).
WithExec(args). WithExec([]string{"flutter", "build", "linux", "--release"}).
Directory("build/linux/x64/release/bundle") Directory("build/linux/x64/release/bundle")
} }
@@ -622,7 +598,7 @@ func (m *Ci) DeployLinux(
sshHost string, sshHost string,
commitHash string, commitHash string,
) (string, error) { ) (string, error) {
bundle := m.BuildLinuxRelease(commitHash) bundle := m.BuildLinuxRelease()
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
@@ -638,27 +614,16 @@ func (m *Ci) DeployLinux(
// setupKeystore decodes the base64 keystore into the android build container. // setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container { func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.androidBase(). return m.setup(m.androidSrc()).
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64). WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword). WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`}) WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
} }
// BuildAndroidApk builds a release APK signed with the upload key. // BuildAndroidApk builds a release APK signed with the upload key.
func (m *Ci) BuildAndroidApk( func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setupKeystore(keystoreBase64, keystorePassword). return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec(args). WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
File("build/app/outputs/flutter-apk/app-release.apk") File("build/app/outputs/flutter-apk/app-release.apk")
} }
@@ -674,7 +639,7 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
buildNumber string, buildNumber string,
) (string, error) { ) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash) apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
@@ -690,7 +655,8 @@ func (m *Ci) DeployApk(
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab. // BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk. // Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory { func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.firebaseBase(). 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"}). WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android"). WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was // --no-daemon avoids connecting to a stale daemon whose registry file was
@@ -749,17 +715,9 @@ func (m *Ci) TestAndroidFirebase(
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it. // BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle. // versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease( func (m *Ci) BuildAndroidRelease() *dagger.File {
// Git commit hash injected as GIT_HASH dart-define so the About page can display it. return m.setup(m.androidSrc()).
// +optional WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
File("build/app/outputs/bundle/release/app-release.aab") File("build/app/outputs/bundle/release/app-release.aab")
} }
@@ -831,41 +789,14 @@ func (m *Ci) PublishAndroid(
playStoreConfig *dagger.Secret, playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret, keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) (string, error) { ) (string, error) {
versionCode := int(time.Now().Unix()) versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease(commitHash) aab := m.BuildAndroidRelease()
stamped := m.StampAndroidVersionCode(aab, versionCode) stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword) signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig) return m.UploadToPlayStore(ctx, signed, playStoreConfig)
} }
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
return dag.Container().
From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
// Graph returns a Mermaid diagram of the CI pipeline structure. // Graph returns a Mermaid diagram of the CI pipeline structure.
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live) // Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
// or save it as a .md file to get a rendered diagram. // or save it as a .md file to get a rendered diagram.
@@ -879,7 +810,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid ` + "```" + `mermaid
flowchart TD flowchart TD
subgraph dagger ["Dagger · Check pipeline"] subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"] toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
pubGet["pubGetLayer\nflutter pub get"] pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"] codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
+1 -1
View File
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
# Add nix profile and nix store tools (task, dagger) to PATH # Add nix profile and nix store tools (task, dagger) to PATH
export PATH="$HOME/.nix-profile/bin:$PATH" export PATH="$HOME/.nix-profile/bin:$PATH"
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1) bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH" [ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
done done
-22
View File
@@ -4,28 +4,6 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26)
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
dependencies up to date. All required components are in main:
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
- `ci/main.go``Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
- `Taskfile.yml``renovate` task
- Issue #257 closed.
## Tasks (2026-05-11) ## Tasks (2026-05-11)
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering - **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
+2 -2
View File
@@ -317,7 +317,7 @@ void main() {
// ── Check Sent folder ────────────────────────────────────────────────── // ── Check Sent folder ──────────────────────────────────────────────────
// Use the drawer to switch folders (no back button on Linux desktop). // Use the drawer to switch folders (no back button on Linux desktop).
await tester.tap(find.byTooltip('Open folders')); await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Sent')); await tester.tap(find.text('Sent'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -331,7 +331,7 @@ void main() {
expect(find.text(subject), findsOneWidget); expect(find.text(subject), findsOneWidget);
// ── Check Inbox ──────────────────────────────────────────────────────── // ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open folders')); await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
-1
View File
@@ -1 +0,0 @@
const int dbSchemaVersion = 36;
-14
View File
@@ -1,14 +0,0 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
}
@@ -11,13 +11,4 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId]. /// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId); Future<void> clearForResync(String accountId);
/// Creates a new mailbox named [name] for [accountId] and tags it with
/// [role] in the local database. For JMAP accounts the role is also sent
/// to the server. Returns the newly created [Mailbox].
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
);
} }
@@ -19,8 +19,6 @@ class SyncLogEntry {
required this.id, required this.id,
required this.result, required this.result,
this.errorMessage, this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol, required this.protocol,
required this.emailsFetched, required this.emailsFetched,
required this.emailsSkipped, required this.emailsSkipped,
@@ -36,8 +34,6 @@ class SyncLogEntry {
final int id; final int id;
final String result; // 'ok' or 'error' final String result; // 'ok' or 'error'
final String? errorMessage; final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap' final String protocol; // 'imap' or 'jmap'
final int emailsFetched; final int emailsFetched;
final int emailsSkipped; final int emailsSkipped;
@@ -58,8 +54,6 @@ abstract class SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -87,8 +81,6 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -1,8 +0,0 @@
import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
}
@@ -92,9 +92,8 @@ class ShareEncryptionService {
) { ) {
if (!s.startsWith(_pubKeyPrefix)) return null; if (!s.startsWith(_pubKeyPrefix)) return null;
try { try {
final data = Uint8List.fromList( final data =
base64.decode(s.substring(_pubKeyPrefix.length)), Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
);
if (data.length != _keyIdLen + _pubKeyLen) return null; if (data.length != _keyIdLen + _pubKeyLen) return null;
return ( return (
keyId: data.sublist(0, _keyIdLen), keyId: data.sublist(0, _keyIdLen),
+2 -3
View File
@@ -108,9 +108,8 @@ class SieveInterpreter {
} }
bool _globMatch(String value, String pattern) { bool _globMatch(String value, String pattern) {
final regexStr = RegExp.escape( final regexStr =
pattern, RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value); return RegExp('^$regexStr\$').hasMatch(value);
} }
+9 -3
View File
@@ -466,7 +466,9 @@ class _Scanner {
String readTaggedArg() { String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord(); if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException('Expected tagged argument at position $_pos'); throw SieveParseException(
'Expected tagged argument at position $_pos',
);
} }
String? peekSizeUnit() { String? peekSizeUnit() {
@@ -478,7 +480,9 @@ class _Scanner {
String readDigits() { String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) { if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException('Expected number at position $_pos'); throw SieveParseException(
'Expected number at position $_pos',
);
} }
final start = _pos; final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) { while (!isAtEnd && _isDigit(_src[_pos])) {
@@ -489,7 +493,9 @@ class _Scanner {
String readQuotedString() { String readQuotedString() {
if (_src[_pos] != '"') { if (_src[_pos] != '"') {
throw SieveParseException('Expected " at position $_pos'); throw SieveParseException(
'Expected " at position $_pos',
);
} }
_pos++; // skip opening quote _pos++; // skip opening quote
final buf = StringBuffer(); final buf = StringBuffer();
-4
View File
@@ -260,8 +260,6 @@ class _AccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
@@ -515,8 +513,6 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap', protocol: 'jmap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
+4 -1
View File
@@ -35,7 +35,10 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"') .replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
.replaceAll("src='cid:$bareCid'", "src='$dataUri'") .replaceAll("src='cid:$bareCid'", "src='$dataUri'")
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"') .replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'"); .replaceAll(
"src='cid:${bareCid.toLowerCase()}'",
"src='$dataUri'",
);
} }
return result; return result;
} }
+1 -42
View File
@@ -6,7 +6,6 @@ import 'package:drift/native.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
part 'database.g.dart'; part 'database.g.dart';
@@ -193,9 +192,6 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true. // Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()(); TextColumn get protocolLog => text().nullable()();
// Added in schema v33: stack trace and permanent flag for error entries.
TextColumn get errorStackTrace => text().nullable()();
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
} }
/// Per-mailbox breakdown for a single sync cycle. /// Per-mailbox breakdown for a single sync cycle.
@@ -307,23 +303,6 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId}; Set<Column> get primaryKey => {accountId, messageId};
} }
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
IntColumn get id => integer()();
// 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ────────────────────────────────────────────────────────────────── // ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase( @DriftDatabase(
@@ -344,14 +323,13 @@ class UserPreferences extends Table {
LocalSieveScripts, LocalSieveScripts,
LocalSieveApplied, LocalSieveApplied,
ShareKeys, ShareKeys,
UserPreferences,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => dbSchemaVersion; int get schemaVersion => 32;
Future<void> _createEmailFts() async { Future<void> _createEmailFts() async {
await customStatement(''' await customStatement('''
@@ -592,25 +570,6 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) { if (from < 32) {
await m.createTable(localSieveApplied); await m.createTable(localSieveApplied);
} }
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
if (from < 34) {
await m.createTable(userPreferences);
}
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
}, },
); );
} }
+16 -11
View File
@@ -9,9 +9,8 @@ class LocalSieveRepository {
final AppDatabase _db; final AppDatabase _db;
Future<List<SieveScript>> listScripts(String accountId) async { Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select( final rows = await (_db.select(_db.localSieveScripts)
_db.localSieveScripts, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
return rows return rows
.map( .map(
@@ -27,9 +26,10 @@ class LocalSieveRepository {
Future<String> getScriptContent(String accountId, String blobId) async { Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId); final rowId = int.parse(blobId);
final row = await (_db.select( final row = await (_db.select(_db.localSieveScripts)
_db.localSieveScripts, ..where(
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) (t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId'); if (row == null) throw Exception('Local script not found: $blobId');
return row.content; return row.content;
@@ -44,7 +44,9 @@ class LocalSieveRepository {
if (id != null) { if (id != null) {
final rowId = int.parse(id); final rowId = int.parse(id);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) ..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write( .write(
LocalSieveScriptsCompanion( LocalSieveScriptsCompanion(
name: Value(name), name: Value(name),
@@ -76,9 +78,10 @@ class LocalSieveRepository {
Future<void> deleteScript(String accountId, String scriptId) async { Future<void> deleteScript(String accountId, String scriptId) async {
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.delete( await (_db.delete(_db.localSieveScripts)
_db.localSieveScripts, ..where(
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) (t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.go(); .go();
} }
@@ -89,7 +92,9 @@ class LocalSieveRepository {
.write(const LocalSieveScriptsCompanion(isActive: Value(false))); .write(const LocalSieveScriptsCompanion(isActive: Value(false)));
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) ..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(const LocalSieveScriptsCompanion(isActive: Value(true))); .write(const LocalSieveScriptsCompanion(isActive: Value(true)));
}); });
} }
@@ -9,8 +9,11 @@ import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect}) DraftRepositoryImpl(
: _imapConnect = imapConnect; this._db,
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -121,7 +124,10 @@ class DraftRepositoryImpl implements DraftRepository {
} }
} }
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async { Future<void> _syncWithServer(
imap.ImapClient client,
String accountId,
) async {
// Create/select the Drafts folder. // Create/select the Drafts folder.
try { try {
await client.createMailbox('Drafts'); await client.createMailbox('Drafts');
@@ -156,9 +162,8 @@ class DraftRepositoryImpl implements DraftRepository {
? uidList.first.toString() ? uidList.first.toString()
: null; : null;
if (uid != null) { if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write( await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
DraftsCompanion(imapServerId: Value(uid)), .write(DraftsCompanion(imapServerId: Value(uid)));
);
} }
} }
@@ -156,7 +156,6 @@ class EmailRepositoryImpl implements EmailRepository {
return; return;
} }
if (threadEmails.isEmpty) return;
final latest = threadEmails.last; final latest = threadEmails.last;
// Collect unique participants across the whole thread. // Collect unique participants across the whole thread.
@@ -238,12 +237,7 @@ class EmailRepositoryImpl implements EmailRepository {
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
final msg = fetch.messages.firstOrNull; final msg = fetch.messages.first;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final textBody = msg.decodeTextPlainPart(); final textBody = msg.decodeTextPlainPart();
final rawHtml = msg.decodeTextHtmlPart(); final rawHtml = msg.decodeTextHtmlPart();
final htmlBody = final htmlBody =
@@ -331,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
], ],
'fetchHTMLBodyValues': true, 'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true, 'fetchTextBodyValues': true,
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'], 'bodyProperties': [
'partId',
'type',
'name',
'size',
'subParts',
],
}, },
'0', '0',
], ],
@@ -1949,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
.getSingleOrNull(); .getSingleOrNull();
final inboxPath = inboxMailbox?.path ?? 'INBOX'; final inboxPath = inboxMailbox?.path ?? 'INBOX';
final alreadyApplied = await (_db.select( final alreadyApplied = await (_db.select(_db.localSieveApplied)
_db.localSieveApplied, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
@@ -2051,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(1)) ..limit(1))
.getSingleOrNull(); .getSingleOrNull();
if (destMailbox == null) { if (destMailbox == null) {
log( log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
);
return; return;
} }
destPath = destMailbox.path; destPath = destMailbox.path;
@@ -2811,13 +2808,11 @@ class EmailRepositoryImpl implements EmailRepository {
// Content-Transfer-Encoding) and getPart() can decode the part correctly. // Content-Transfer-Encoding) and getPart() can decode the part correctly.
// A partial BODY.PEEK[n] fetch omits those headers, causing // A partial BODY.PEEK[n] fetch omits those headers, causing
// decodeContentBinary() to return raw base64 instead of decoded bytes. // decodeContentBinary() to return raw base64 instead of decoded bytes.
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]'); final fetch = await client.uidFetchMessage(
final msg = fetch.messages.firstOrNull; emailRow.uid,
if (msg == null) { 'BODY.PEEK[]',
throw StateError( );
'IMAP server returned no message for UID ${emailRow.uid}.', final msg = fetch.messages.first;
);
}
final part = msg.getPart(attachment.fetchPartId) ?? msg; final part = msg.getPart(attachment.fetchPartId) ?? msg;
final bytes = part.decodeContentBinary(); final bytes = part.decodeContentBinary();
if (bytes == null) { if (bytes == null) {
@@ -2879,14 +2874,11 @@ class EmailRepositoryImpl implements EmailRepository {
); );
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]'); final fetch = await client.uidFetchMessage(
final msg = fetch.messages.firstOrNull; emailRow.uid,
if (msg == null) { 'BODY.PEEK[]',
throw StateError( );
'IMAP server returned no message for UID ${emailRow.uid}.', return fetch.messages.first.renderMessage();
);
}
return msg.renderMessage();
} finally { } finally {
await client.logout(); await client.logout();
} }
@@ -3260,17 +3252,14 @@ class EmailRepositoryImpl implements EmailRepository {
await _db.customStatement('PRAGMA foreign_keys = OFF'); await _db.customStatement('PRAGMA foreign_keys = OFF');
try { try {
await _db.transaction(() async { await _db.transaction(() async {
await (_db.delete( await (_db.delete(_db.emails)
_db.emails, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete( await (_db.delete(_db.pendingChanges)
_db.pendingChanges, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete( await (_db.delete(_db.syncStates)
_db.syncStates, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
}); });
} finally { } finally {
@@ -79,15 +79,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
try { try {
final mailboxes = await client.listMailboxes(recursive: true); final mailboxes = await client.listMailboxes(recursive: true);
// Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(
_db.mailboxes,
)..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) { for (final mb in mailboxes) {
final path = mb.path; final path = mb.path;
final id = '${account.id}:$path'; final id = '${account.id}:$path';
@@ -105,12 +96,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e'); log('STATUS skipped for $path: $e');
} }
// Use the server-assigned role when available; fall back to the
// existing DB role so that manually-created folders (e.g. a user
// who just created their Archive folder) keep their role across syncs
// when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id];
await _db.into(_db.mailboxes).insertOnConflictUpdate( await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
@@ -119,7 +104,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name, name: mb.name,
unreadCount: Value(unread), unreadCount: Value(unread),
totalCount: Value(total), totalCount: Value(total),
role: Value(role), role: Value(_imapRole(mb)),
), ),
); );
} }
@@ -321,112 +306,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete( await (_db.delete(_db.mailboxes)
_db.mailboxes, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@override
Future<model.Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, role);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, role);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String role,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.createMailbox(name);
} finally {
await client.logout();
}
final id = '${account.id}:$name';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: name,
name: name,
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
Future<model.Mailbox> _createMailboxWithRoleJmap(
account_model.Account account,
String password,
String name,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final responses = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
],
]);
final result = _responseArgs(responses, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newId =
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
if (newId == null) {
throw Exception(
'Failed to create mailbox "$name": server returned no ID',
);
}
final dbId = '${account.id}:$newId';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: account.id,
path: newId,
name: name,
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
} }
@@ -24,9 +24,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
await _db.transaction(() async { await _db.transaction(() async {
// Remove existing entry for same query (deduplication). // Remove existing entry for same query (deduplication).
await (_db.delete( await (_db.delete(_db.searchHistoryEntries)
_db.searchHistoryEntries, ..where((t) => t.query.equals(trimmed)))
)..where((t) => t.query.equals(trimmed)))
.go(); .go();
await _db.into(_db.searchHistoryEntries).insert( await _db.into(_db.searchHistoryEntries).insert(
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
.get(); .get();
if (keepIds.isNotEmpty) { if (keepIds.isNotEmpty) {
await (_db.delete( await (_db.delete(_db.searchHistoryEntries)
_db.searchHistoryEntries, ..where((t) => t.id.isNotIn(keepIds)))
)..where((t) => t.id.isNotIn(keepIds)))
.go(); .go();
} }
}); });
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
await _pruneExpired(); await _pruneExpired();
final keyIdHex = _hex(keyId); final keyIdHex = _hex(keyId);
final row = await (_db.select( final row = await (_db.select(_db.shareKeys)
_db.shareKeys, ..where((t) => t.id.equals(keyIdHex)))
)..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) return null; if (row == null) return null;
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
} }
Future<void> _pruneExpired() async { Future<void> _pruneExpired() async {
await (_db.delete( await (_db.delete(_db.shareKeys)
_db.shareKeys, ..where(
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()))) (t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
))
.go(); .go();
} }
@@ -13,8 +13,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -32,8 +30,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId, accountId: accountId,
result: success ? 'ok' : 'error', result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage), errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol), protocol: Value(protocol),
itemsSynced: Value(emailsFetched), itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped), emailsSkipped: Value(emailsSkipped),
@@ -79,8 +75,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id, id: r.id,
result: r.result, result: r.result,
errorMessage: r.errorMessage, errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol, protocol: r.protocol,
emailsFetched: r.itemsSynced, emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped, emailsSkipped: r.emailsSkipped,
@@ -1,70 +0,0 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
UserPreferencesRepositoryImpl(this._db);
final AppDatabase _db;
static const _rowId = 1;
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@override
Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
menuPosition: Value(position.name),
),
);
}
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences();
return pref.UserPreferences(
menuPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom,
),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
);
}
}
+7 -27
View File
@@ -5,7 +5,6 @@ import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
@@ -14,7 +13,6 @@ import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_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/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -23,8 +21,7 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart'; import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart' import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
hide Email, EmailBody, UserPreferences;
import 'package:sharedinbox/data/db/local_sieve_repository.dart'; import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart'; import 'package:sharedinbox/data/jmap/sieve_repository.dart';
@@ -36,7 +33,6 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
/// Swappable IMAP connection factory — override in tests to use plaintext. /// Swappable IMAP connection factory — override in tests to use plaintext.
@@ -101,9 +97,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>(( final searchHistoryRepositoryProvider =
ref, Provider<SearchHistoryRepository>((ref) {
) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -136,10 +131,8 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>(( final isSyncingProvider =
ref, StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
accountId,
) {
return ref.watch(syncManagerProvider).watchSyncing(accountId); return ref.watch(syncManagerProvider).watchSyncing(accountId);
}); });
@@ -188,9 +181,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>( final undoServiceProvider =
UndoService.new, NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
);
/// Loads email header + body and marks the email as seen. /// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -235,15 +227,3 @@ final accountConnectionStatusProvider =
.read(connectionTestServiceProvider) .read(connectionTestServiceProvider)
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
ref,
) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
ref,
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
-5
View File
@@ -20,7 +20,6 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart';
final router = GoRouter( final router = GoRouter(
@@ -57,10 +56,6 @@ final router = GoRouter(
path: 'about', path: 'about',
builder: (ctx, state) => const AboutScreen(), builder: (ctx, state) => const AboutScreen(),
), ),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
+59 -68
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -7,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
@@ -19,22 +19,57 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform(); final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture;
late final Stream<List<Account>> _accountsStream; late final Stream<List<Account>> _accountsStream;
String? _deviceModel;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts(); _accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
_deviceModelFuture = getDeviceModel();
unawaited(
_deviceModelFuture.then((model) {
if (mounted) setState(() => _deviceModel = model);
}),
);
} }
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard( Future<void> _copyToClipboard(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -44,20 +79,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: buildAboutMarkdown( text: _buildMarkdown(context, pkg, imapCount, jmapCount),
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
), ),
); );
if (context.mounted) { if (context.mounted) {
@@ -70,32 +95,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
} }
} }
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
Future<void> _createIssue( Future<void> _createIssue(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -105,28 +104,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
final body = Uri.encodeComponent( final body = Uri.encodeComponent(
buildAboutMarkdown( _buildMarkdown(context, pkg, imapCount, jmapCount),
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
); );
final url = Uri.parse( final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
); );
try { try {
final launched = await launchUrl( final launched =
url, await launchUrl(url, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -170,17 +157,21 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Markdown( return Markdown(
data: buildAboutMarkdown( data: _buildMarkdown(
context: context, context,
pkg: snapshot.data, snapshot.data,
imapCount: imapCount, imapCount,
jmapCount: jmapCount, jmapCount,
deviceModel: _deviceModel,
), ),
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited(_launchUrl(context, Uri.parse(href))); unawaited(
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
} }
}, },
); );
+71 -112
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -67,14 +66,6 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/about')); unawaited(context.push('/accounts/about'));
}, },
), ),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/preferences'));
},
),
], ],
), ),
), ),
@@ -120,80 +111,20 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id)); final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP'; final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return Column( return ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
ListTile( subtitle: Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Text('${account.email}\n$typeLabel'), Text('${account.email}\n$typeLabel'),
isThreeLine: true, const SizedBox(height: 4),
trailing: Row( health.when(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) { data: (h) {
if (h == null) return const Text('Sync health: Not verified yet'); if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row( return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Sync health: '), const Text('Sync health: '),
Icon( Icon(
@@ -202,13 +133,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)), Text(' ($date)', style: const TextStyle(fontSize: 10)),
], ],
); );
@@ -216,8 +141,66 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'), loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'), error: (e, _) => Text('Sync health error: $e'),
), ),
), ],
], ),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
); );
} }
@@ -310,30 +293,6 @@ class _AccountTile extends ConsumerWidget {
} }
} }
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget { class _OnboardingView extends StatelessWidget {
const _OnboardingView(); const _OnboardingView();
+20 -49
View File
@@ -32,7 +32,6 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> { class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey; _Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial; ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr; String? _pubKeyQr;
String? _errorMessage; String? _errorMessage;
bool _scannerActive = false; bool _scannerActive = false;
@@ -65,7 +64,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
); );
setState(() { setState(() {
_keyMaterial = material; _keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr; _pubKeyQr = qr;
_step = _Step.showingPubKey; _step = _Step.showingPubKey;
}); });
@@ -87,24 +85,22 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
} }
} }
// Pre-flight: probe the scanner's permission-state method to verify the // Pre-flight: start + stop the scanner to verify the plugin is available.
// plugin is registered. MissingPluginException is thrown on Android builds // Falls back to text entry on any exception (including MissingPluginException).
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async { Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false; bool available = false;
try { try {
await const MethodChannel( ctrl = MobileScannerController();
'dev.steenbakker.mobile_scanner/scanner/method', await ctrl.start();
).invokeMethod<int>('state'); await ctrl.stop();
available = true; available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) { } catch (_) {
// Plugin registered but state check failed; let the scanner widget // Plugin not available on this device; text fallback will be shown.
// handle it via its errorBuilder. } finally {
available = true; try {
await ctrl?.dispose();
} catch (_) {}
} }
if (!mounted) return; if (!mounted) return;
if (available) { if (available) {
@@ -219,7 +215,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
), ),
), ),
_Step.done => const Center( _Step.done => const Center(
child: Icon(Icons.check_circle, size: 64, color: Colors.green), child: Icon(
Icons.check_circle,
size: 64,
color: Colors.green,
),
), ),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
@@ -274,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_ExpiryHint(expiresAt: _keyExpiresAt!), const _ExpiryHint(),
const SizedBox(height: 32), const SizedBox(height: 32),
if (_errorMessage != null) ...[ if (_errorMessage != null) ...[
Text( Text(
@@ -404,37 +404,8 @@ bool _cameraScanSupported() =>
Platform.isMacOS || Platform.isMacOS ||
Platform.isWindows; Platform.isWindows;
class _ExpiryHint extends StatefulWidget { class _ExpiryHint extends StatelessWidget {
const _ExpiryHint({required this.expiresAt}); const _ExpiryHint();
final DateTime expiresAt;
@override
State<_ExpiryHint> createState() => _ExpiryHintState();
}
class _ExpiryHintState extends State<_ExpiryHint> {
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String _formatRemaining() {
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -444,7 +415,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]), Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'This key expires in ${_formatRemaining()}', 'This key expires in 20 minutes',
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
], ],
+18 -15
View File
@@ -57,24 +57,22 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
} }
} }
// Pre-flight: probe the scanner's permission-state method to verify the // Pre-flight: start + stop the scanner to verify the plugin is available.
// plugin is registered. MissingPluginException is thrown on Android builds // Falls back to text entry on any exception (including MissingPluginException).
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async { Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false; bool available = false;
try { try {
await const MethodChannel( ctrl = MobileScannerController();
'dev.steenbakker.mobile_scanner/scanner/method', await ctrl.start();
).invokeMethod<int>('state'); await ctrl.stop();
available = true; available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) { } catch (_) {
// Plugin registered but state check failed; let the scanner widget // Plugin not available on this device; text fallback will be shown.
// handle it via its errorBuilder. } finally {
available = true; try {
await ctrl?.dispose();
} catch (_) {}
} }
if (!mounted) return; if (!mounted) return;
if (available) { if (available) {
@@ -158,7 +156,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
for (final account in selected) { for (final account in selected) {
final password = await repo.getPassword(account.id); final password = await repo.getPassword(account.id);
payloads.add( payloads.add(
AccountPayload(accountJson: account.toJson(), password: password), AccountPayload(
accountJson: account.toJson(),
password: password,
),
); );
} }
@@ -358,7 +359,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!))); unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Encrypted code copied to clipboard'), content: Text(
'Encrypted code copied to clipboard',
),
), ),
); );
}, },
+2 -3
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -12,9 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')), appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: DefaultAssetBundle.of( future: rootBundle.loadString('assets/changelog.txt'),
context,
).loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
+9 -3
View File
@@ -194,7 +194,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Failed to open file: $e'), content: Text('Failed to open file: $e'),
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Select an account first'), content: Text('Select an account first'),
@@ -251,7 +255,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Send failed: $e'), content: Text('Send failed: $e'),
+5 -33
View File
@@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -18,23 +17,12 @@ class CrashScreen extends StatelessWidget {
final StackTrace? stackTrace; final StackTrace? stackTrace;
final String gitHash; final String gitHash;
String get _buildMode { Future<String> _buildReport() async {
if (kDebugMode) return 'debug'; String version = 'unknown';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _fetchVersion() async {
try { try {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}'; version = '${info.version}+${info.buildNumber}';
} catch (_) { } catch (_) {}
return 'unknown';
}
}
Future<String> _buildReport() async {
final version = await _fetchVersion();
final platform = final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final versionDisplay = gitHash.isNotEmpty final versionDisplay = gitHash.isNotEmpty
@@ -43,13 +31,9 @@ class CrashScreen extends StatelessWidget {
final gitLine = gitHash.isNotEmpty final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n' ? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: ''; : '';
final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n' return 'App Version: $versionDisplay\n'
'Build Mode: $_buildMode\n'
'$gitLine' '$gitLine'
'Platform: $platform\n' 'Platform: $platform\n\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
'Error:\n```\n$exception\n```\n\n' 'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```'; 'Stack Trace:\n```\n$stackTrace\n```';
} }
@@ -75,18 +59,6 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium, style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 4),
FutureBuilder<String>(
future: _fetchVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
),
if (gitHash.isNotEmpty) ...[ if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
FutureBuilder<PackageInfo>( FutureBuilder<PackageInfo>(
+2 -11
View File
@@ -51,7 +51,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild); _smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild); _sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild); _imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load()); unawaited(_load());
} }
@@ -91,7 +90,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild); _smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild); _sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild); _imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [ for (final c in [
_displayNameCtrl, _displayNameCtrl,
_usernameCtrl, _usernameCtrl,
@@ -355,17 +353,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting, testing: _tryTesting,
okMessage: _tryOk, okMessage: _tryOk,
errorMessage: _tryErr, errorMessage: _tryErr,
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty onPressed: _tryConnection,
? _tryConnection
: null,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton( FilledButton(onPressed: _save, child: const Text('Save')),
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _save
: null,
child: const Text('Save'),
),
], ],
), ),
), ),
-80
View File
@@ -1,80 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m in mailboxes.where(
(m) => m.path != currentMailboxPath,
))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+70 -316
View File
@@ -13,11 +13,9 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -72,23 +70,61 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_replyWithRecipientDialog(context, header, body)); unawaited(_reply(context, header, body, replyAll: false));
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.archive), icon: const Icon(Icons.reply_all),
tooltip: 'Archive', tooltip: 'Reply all',
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_archive(context, header)); unawaited(_reply(context, header, body, replyAll: true));
}, },
), ),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
tooltip: 'Mark as unread',
onPressed: () async {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
tooltip: 'Delete', tooltip: 'Delete',
onPressed: () async { onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId); final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) { if (header != null) {
@@ -107,32 +143,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
if (context.mounted) _navigateTo(context, header, nextEmailId); if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
}, },
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(value: 'forward', child: Text('Forward')),
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -141,22 +156,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure', value: 'structure',
child: Text('Show Mail Structure'), child: Text('Show Mail Structure'),
), ),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')), const PopupMenuItem(
value: 'rfc',
child: Text('Show Raw Email'),
),
], ],
onSelected: (value) async { onSelected: (value) {
if (value == 'forward' && header != null) { if (value == 'headers' && body != null) {
unawaited(_forward(context, header, body));
} else if (value == 'move' && header != null) {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
_showStructure(context, body); _showStructure(context, body);
@@ -235,40 +241,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(widget.emailId),
);
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async { Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename)); setState(() => _downloading.add(att.filename));
try { try {
@@ -331,78 +303,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return '\n\n— On $date, $from wrote:\n$quoted'; return '\n\n— On $date, $from wrote:\n$quoted';
} }
Future<void> _replyWithRecipientDialog( Future<void> _reply(
BuildContext context,
Email header,
EmailBody? body,
) async {
final account =
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
final ownEmail = account?.email.toLowerCase() ?? '';
final seen = <String>{};
final candidates = <_Candidate>[];
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
final key = addr.email.toLowerCase();
if (key == ownEmail || seen.contains(key)) return;
seen.add(key);
candidates.add(_Candidate(addr, defaultPlacement));
}
for (final addr in header.from) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.to) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.cc) {
addIfNew(addr, _Placement.cc);
}
if (!context.mounted) return;
if (candidates.length <= 1) {
final to = candidates
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = candidates
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
return;
}
final confirmed = await showDialog<List<_Candidate>>(
context: context,
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
);
if (confirmed == null || !context.mounted) return;
final to = confirmed
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = confirmed
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
}
Future<void> _composeReply(
BuildContext context, BuildContext context,
Email header, Email header,
EmailBody? body, { EmailBody? body, {
required String to, required bool replyAll,
required String cc,
}) async { }) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false) final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject! ? header.subject!
: 'Re: ${header.subject ?? ''}'; : 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body); final quoted = await _quotedBody(header, body);
if (!context.mounted) return; if (!context.mounted) return;
unawaited( unawaited(
@@ -419,78 +330,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<void> _archive(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _forward( Future<void> _forward(
BuildContext context, BuildContext context,
Email header, Email header,
@@ -504,14 +343,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: {'prefillSubject': subject, 'prefillBody': quoted}, extra: {
'prefillSubject': subject,
'prefillBody': quoted,
},
), ),
); );
} }
Future<void> _moveTo(BuildContext context, Email header) async { Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first; await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -560,13 +400,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) _navigateTo(context, header, nextEmailId); if (context.mounted) context.pop();
} }
Future<void> _snooze(BuildContext context, Email header) async { Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>( final until = await showModalBottomSheet<DateTime>(
context: context, context: context,
builder: (ctx) => const SnoozePicker(), builder: (ctx) => const SnoozePicker(),
@@ -594,7 +431,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
); );
_navigateTo(context, header, nextEmailId); context.pop();
} }
} }
@@ -606,9 +443,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.fetchRawRfc822(widget.emailId); .fetchRawRfc822(widget.emailId);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Failed to fetch raw email: $e')),
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e'))); );
return; return;
} }
@@ -773,7 +610,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Structure not available. Try re-syncing the email.'), content: Text(
'Structure not available. Try re-syncing the email.',
),
), ),
); );
return; return;
@@ -831,88 +670,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
} }
enum _Placement { to, cc, skip }
class _Candidate {
_Candidate(this.address, this.placement);
final EmailAddress address;
_Placement placement;
}
class _ReplyAllDialog extends StatefulWidget {
const _ReplyAllDialog({required this.candidates});
final List<_Candidate> candidates;
@override
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
}
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
late final List<_Candidate> _candidates;
@override
void initState() {
super.initState();
_candidates = [
for (final c in widget.candidates) _Candidate(c.address, c.placement),
];
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Reply All'),
content: SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: [
for (final c in _candidates)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
c.address.toString(),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
SegmentedButton<_Placement>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: _Placement.to, label: Text('To')),
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
ButtonSegment(
value: _Placement.skip,
label: Text('Skip'),
),
],
selected: {c.placement},
onSelectionChanged: (s) =>
setState(() => c.placement = s.first),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _candidates),
child: const Text('Reply'),
),
],
);
}
}
class _MimeRow { class _MimeRow {
const _MimeRow(this.depth, this.label); const _MimeRow(this.depth, this.label);
final int depth; final int depth;
@@ -955,13 +712,10 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header); final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink(); if (uri == null) return const SizedBox.shrink();
return Tooltip( return ActionChip(
message: uri.toString(), avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
child: ActionChip( label: const Text('Unsubscribe'),
avatar: const Icon(Icons.unsubscribe_outlined, size: 16), onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
); );
} }
} }
+32 -60
View File
@@ -8,10 +8,8 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -149,21 +147,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom), appBar: _buildAppBar(repo, accountAsync),
drawer: _selecting drawer: _selecting
? null ? null
: FolderDrawer( : FolderDrawer(
accountId: widget.accountId, accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath, currentMailboxPath: widget.mailboxPath,
), ),
bottomNavigationBar: _selecting bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
body: Column( body: Column(
children: [ children: [
_buildSyncErrorBanner(), _buildSyncErrorBanner(),
@@ -179,14 +172,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar( PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo, EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, { AsyncValue<Account?> accountAsync,
required bool menuAtBottom, ) {
}) {
final selectionCount = final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length; _searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar( return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting leading: _selecting
? IconButton( ? IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -309,22 +300,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
@@ -381,7 +356,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
return MaterialBanner( return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis), content: Text(
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon( leading: Icon(
Icons.sync_problem, Icons.sync_problem,
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
@@ -395,8 +374,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: const Text('Retry'), child: const Text('Retry'),
), ),
TextButton( TextButton(
onPressed: () => onPressed: () => context.push(
context.push('/accounts/${widget.accountId}/sync-log'), '/accounts/${widget.accountId}/sync-log',
),
child: const Text('View log'), child: const Text('View log'),
), ),
TextButton( TextButton(
@@ -440,26 +420,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Future<void> _batchMoveToRole( Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
_clearSelection(); _clearSelection();
final mailbox = await ref
final mailbox = await resolveMailboxByRole( .read(mailboxRepositoryProvider)
context, .findMailboxByRole(widget.accountId, role);
ref.read(mailboxRepositoryProvider), if (!mounted) return;
widget.accountId, if (mailbox == null) {
widget.mailboxPath, ScaffoldMessenger.of(
role, context,
dialogTitle: dialogTitle, ).showSnackBar(
createFolderName: createFolderName, SnackBar(
); duration: const Duration(seconds: 5),
content: Text(notFoundMessage),
if (!mounted || mailbox == null) return; ),
);
return;
}
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -485,11 +463,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
} }
Future<void> _batchArchive() => _batchMoveToRole( Future<void> _batchArchive() =>
'archive', _batchMoveToRole('archive', 'No archive folder found');
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _refreshSearchAndPopIfEmpty() async { Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return; if (!mounted || !_searching) return;
@@ -568,11 +543,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
} }
Future<void> _batchMarkSpam() => _batchMoveToRole( Future<void> _batchMarkSpam() =>
'junk', _batchMoveToRole('junk', 'No spam folder found');
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMove() async { Future<void> _batchMove() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
-18
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider); final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider); final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId)); final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'), title: const Text('Folders'),
actions: [ actions: [
IconButton( IconButton(
@@ -47,19 +42,6 @@ class MailboxListScreen extends ConsumerWidget {
], ],
), ),
drawer: FolderDrawer(accountId: accountId), drawer: FolderDrawer(accountId: accountId),
bottomNavigationBar: menuAtBottom
? BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
],
),
)
: null,
body: Column( body: Column(
children: [ children: [
// ── Failed-mutation banner ─────────────────────────────────────── // ── Failed-mutation banner ───────────────────────────────────────
+2 -3
View File
@@ -10,9 +10,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>(( final _searchHistoryProvider =
ref, FutureProvider.autoDispose<List<String>>((ref) async {
) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches(); return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
}); });
+3 -1
View File
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'), title: Text(
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
), ),
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+5 -139
View File
@@ -1,15 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss'); final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -25,57 +21,6 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} }
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
final buf = StringBuffer();
buf.writeln('## Sync Entry');
buf.writeln();
buf.writeln('| Property | Value |');
buf.writeln('|----------|-------|');
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
if (entry.protocol.isNotEmpty) {
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
}
final statusLabel = entry.isOk
? 'OK'
: entry.isPermanent
? 'Error (permanent)'
: 'Error';
buf.writeln('| Status | $statusLabel |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
if (entry.mailboxStats.isNotEmpty) {
buf.writeln();
buf.writeln('### Per mailbox');
buf.writeln();
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
buf.writeln('|---------|---------|------------|----------|');
for (final m in entry.mailboxStats) {
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
}
}
if (entry.errorMessage != null) {
buf.writeln();
buf.writeln('**Error:**');
buf.writeln();
buf.writeln(entry.errorMessage);
}
if (entry.stackTrace != null) {
buf.writeln();
buf.writeln('**Stack trace:**');
buf.writeln();
buf.writeln('```');
buf.write(entry.stackTrace);
buf.writeln('```');
}
return buf.toString();
}
class SyncLogScreen extends ConsumerStatefulWidget { class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId}); const SyncLogScreen({super.key, required this.accountId});
@@ -124,41 +69,6 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId); ref.read(syncManagerProvider).syncNow(widget.accountId);
} }
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
PackageInfo? pkg;
try {
pkg = await PackageInfo.fromPlatform();
} catch (_) {}
final deviceModel = await getDeviceModel();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
);
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 3),
content: Text('Copied to clipboard'),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -186,20 +96,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet')) ? const Center(child: Text('No sync entries yet'))
: ListView.builder( : ListView.builder(
itemCount: _entries.length, itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile( itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
entry: _entries[i],
onCopy: () => _copyEntry(_entries[i], ctx),
),
), ),
); );
} }
} }
class _SyncLogTile extends StatelessWidget { class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry, required this.onCopy}); const _SyncLogTile({required this.entry});
final SyncLogEntry entry; final SyncLogEntry entry;
final VoidCallback onCopy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -209,12 +115,6 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final errorColor = theme.colorScheme.error; final errorColor = theme.colorScheme.error;
final subtitleText = entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: entry.isPermanent
? 'Error (permanent) · took $durationLabel'
: 'Error · took $durationLabel';
return ExpansionTile( return ExpansionTile(
leading: Icon( leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline, entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -225,20 +125,11 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor), style: entry.isOk ? null : TextStyle(color: errorColor),
), ),
subtitle: Text( subtitle: Text(
subtitleText, entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor), style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
), ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy as markdown',
onPressed: onCopy,
),
const Icon(Icons.expand_more),
],
),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12), padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -280,31 +171,6 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12), style: TextStyle(color: errorColor, fontSize: 12),
), ),
), ),
if (entry.stackTrace != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
child: Text(
'Stack trace',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.stackTrace!,
style: TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: Colors.red[300],
),
),
),
],
if (entry.protocolLog != null) ...[ if (entry.protocolLog != null) ...[
const Padding( const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2), padding: EdgeInsets.only(top: 6, bottom: 2),
+1 -34
View File
@@ -7,7 +7,6 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -29,16 +28,9 @@ class ThreadDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Thread')),
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -68,20 +60,6 @@ class ThreadDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
} }
class _EmailMessageCard extends ConsumerStatefulWidget { class _EmailMessageCard extends ConsumerStatefulWidget {
@@ -163,17 +141,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
FutureBuilder<EmailBody>( FutureBuilder<EmailBody>(
future: _bodyFuture, future: _bodyFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Failed to load email: ${snapshot.error}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
);
}
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center( return const Center(
child: Padding( child: Padding(
+3 -1
View File
@@ -84,7 +84,9 @@ class _UndoActionTile extends ConsumerWidget {
.read(undoServiceProvider.notifier) .read(undoServiceProvider.notifier)
.undo(actionId: action.id); .undo(actionId: action.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Action undone.'), content: Text('Action undone.'),
-139
View File
@@ -1,139 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
const UserPreferencesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
body: prefsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading preferences')),
data: (prefs) => ListView(
children: [
ListTile(
title: Text(
'Menu bar position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the folder navigation menu is shown in the mailbox view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.menuPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMenuPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Open folder navigation from a button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Open folder navigation from the hamburger icon in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text('Show the back button in the top bar.'),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text('Show the next message in the mailbox.'),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text('Return to the message list.'),
value: AfterMailViewAction.showMailbox,
),
],
),
),
],
),
),
);
}
}
-76
View File
@@ -1,76 +0,0 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
String buildAboutMarkdown({
required BuildContext context,
PackageInfo? pkg,
required int imapCount,
required int jmapCount,
String? deviceModel,
}) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString();
final textScale = MediaQuery.of(
context,
).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceModelLine =
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$deviceModelLine'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| Locale | $locale |\n'
'| Text Scale | $textScale× |\n'
'| DB Schema Version | $dbSchemaVersion |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
/// Fetches device model string, or null when unavailable.
Future<String?> getDeviceModel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.manufacturer} / ${android.model}';
} else if (Platform.isIOS) {
final ios = await info.iosInfo;
return ios.utsname.machine;
}
} catch (_) {}
return null;
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
+13 -22
View File
@@ -31,13 +31,10 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp"> <meta http-equiv="Content-Security-Policy" content="$csp">
<style> <style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; } body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
a { color: #1976D2; } a { color: #1976D2; }
* { box-sizing: border-box; max-width: 100%; } * { box-sizing: border-box; }
table { width: 100%; border-collapse: collapse; }
td, th { overflow-wrap: break-word; word-break: break-word; }
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
</style> </style>
</head> </head>
<body> <body>
@@ -111,16 +108,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
Future<void> _measureHeight(String _) async { Future<void> _measureHeight(String _) async {
try { final result = await _controller!.runJavaScriptReturningResult(
final result = await _controller!.runJavaScriptReturningResult( 'document.documentElement.scrollHeight',
'document.documentElement.scrollHeight', );
); final h = double.tryParse(result.toString());
final h = double.tryParse(result.toString()); if (h != null && h > 0 && mounted) {
if (h != null && h > 0 && mounted) { setState(() => _height = h);
setState(() => _height = h);
}
} catch (_) {
// WebView not ready yet; height stays at default
} }
} }
@@ -191,14 +184,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final launched = await launchUrl( final launched =
uri, await launchUrl(uri, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) { if (!launched && mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Could not open: $url')),
).showSnackBar(SnackBar(content: Text('Could not open: $url'))); );
} }
} }
} }
+11 -35
View File
@@ -249,22 +249,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -659,10 +643,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1088,26 +1072,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.31.0" version: "1.30.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.10"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.17" version: "0.6.16"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@@ -1133,13 +1117,13 @@ packages:
source: hosted source: hosted
version: "6.3.2" version: "6.3.2"
url_launcher_android: url_launcher_android:
dependency: "direct overridden" dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.24" version: "6.3.30"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@@ -1300,14 +1284,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.3.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
workmanager: workmanager:
dependency: "direct main" dependency: "direct main"
description: description:
+1 -6
View File
@@ -33,7 +33,7 @@ dependencies:
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
# Date formatting # Date formatting
intl: ^0.20.2 intl: any
# File picking (compose attachments) and opening downloaded attachments # File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4 file_picker: ^12.0.0-beta.4
@@ -61,7 +61,6 @@ dependencies:
# App version metadata for crash reports # App version metadata for crash reports
package_info_plus: ^10.1.0 package_info_plus: ^10.1.0
share_plus: ^13.1.0 share_plus: ^13.1.0
device_info_plus: ^13.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -90,7 +89,3 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses # (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably. # stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21" path_provider_android: ">=2.2.0 <2.2.21"
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
# channel-error on launchUrl on some Android devices (same root cause as
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
url_launcher_android: ">=6.3.0 <6.3.25"
-16
View File
@@ -1,16 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"labels": ["dependencies"],
"github-actions": {
"enabled": false
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
}
]
}
+849
View File
@@ -0,0 +1,849 @@
#!/usr/bin/env python3
"""
agent_loop.py called from cron every 10 minutes.
Flow
----
1. Agent already running?
a. Age > 1 h kill it, set its issue to State/Question, exit 1
b. Age 1 h print status, exit 0 (let it keep working)
2. No agent running extract pending_issue from state (if any), then check CI
a. pending_issue + open PR check PR branch CI, merge/fix/wait as needed
b. Catch-up: orphaned issue-N-fix PRs with passing CI merge them
c. Main CI running save pending-ci state, exit 0
d. Main CI failed start fix-CI agent (pushes fix to main), exit 0
e. Main CI ok + pending_issue close the issue, exit 0 (dead code path
section 2a always returns first)
f. Main CI ok (or no run yet) find oldest Ready issue, start issue agent,
save state, exit 0
g. No Ready issues print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first:
scripts/agent_loop.py list # shows NAME and UUID columns
claude --resume <uuid> # use the UUID, NOT the session name
"""
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
os.environ["PATH"] = (
f"{Path.home()}/.nix-profile/bin"
f":{Path.home()}/go/bin"
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
)
# ── configuration ─────────────────────────────────────────────────────────────
REPO = "guettli/sharedinbox"
REPO_URL = f"https://codeberg.org/{REPO}"
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
"-" + str(Path.home())[1:].replace("/", "-")
)
# Labels used by the workflow.
LABEL_READY = "State/Ready"
LABEL_IN_PROGRESS = "State/InProgress"
LABEL_QUESTION = "State/Question"
LABEL_PRIO_HIGH = "Prio/High"
# Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
# ── helpers ───────────────────────────────────────────────────────────────────
def _issue_url(number: int) -> str:
return f"{REPO_URL}/issues/{number}"
def _ci_run_url(run_id: int) -> str:
return f"{REPO_URL}/actions/runs/{run_id}"
def _fgj(*args: str) -> None:
"""Run a fgj command, raising on failure."""
cmd = ["fgj", "--hostname", "codeberg.org", *args]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"fgj {' '.join(args)} failed:\n{result.stderr or result.stdout}"
)
def _tea_get(path: str) -> dict | list | None:
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
cmd = ["tea", "api", path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"tea api {path} failed:\n{result.stderr or result.stdout}"
)
out = result.stdout.strip()
if not out:
return None
data = json.loads(out)
if isinstance(data, dict) and "message" in data and "url" in data:
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
return data
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
"""Add/remove labels on an issue via fgj."""
cmd = ["issue", "edit", str(issue), "--repo", REPO]
for label in add:
cmd += ["--add-label", label]
for label in remove:
cmd += ["--remove-label", label]
_fgj(*cmd)
def _close_issue(issue: int) -> None:
_fgj("issue", "close", str(issue), "--repo", REPO)
_set_labels(issue, add=[], remove=[LABEL_IN_PROGRESS])
def _comment_issue(issue: int, body: str) -> None:
_fgj("issue", "comment", str(issue), "--repo", REPO, "--body", body)
def _ready_issues() -> list[dict]:
"""Return open issues with State/Ready, Prio/High first, then oldest."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "issue", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout) if result.stdout.strip() else []
ready = [
i for i in data
if any(lbl["name"] == LABEL_READY for lbl in i.get("labels", []))
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
]
ready.sort(key=lambda i: (
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
i["number"],
))
return ready
def _latest_main_ci_run() -> dict | None:
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
Using the global latest run (limit=1) is wrong: a passing or failing run
on a PR branch could mask the true state of main. We filter to push
events on the 'main' prettyref so section-3 logic only reacts to main.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if run.get("event") == "push" and run.get("prettyref") == "main":
return run
return None
def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None.
Forgejo's workflow_runs API has no top-level head_branch field.
For push events the branch is in ``prettyref``; for pull_request
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if run.get("event") == "pull_request":
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
return run
except (json.JSONDecodeError, AttributeError):
pass
elif run.get("event") == "push":
if run.get("prettyref") == branch:
return run
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."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", state, "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return None
prs = json.loads(result.stdout)
for pr in prs:
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if ref == branch:
return pr
return None
def _open_issue_prs() -> list[dict]:
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
prs = json.loads(result.stdout)
issue_prs = []
for pr in prs:
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
issue_prs.append(pr)
issue_prs.sort(key=lambda p: p["number"])
return issue_prs
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
return None
def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
# ── state file ────────────────────────────────────────────────────────────────
def _read_state() -> dict | None:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return None
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
data: dict = {
"pid": pid,
"issue": issue,
"started_at": datetime.now(timezone.utc).isoformat(),
"type": kind,
}
if issue_title is not None:
data["issue_title"] = issue_title
if session_name is not None:
data["session_name"] = session_name
if ci_run_id is not None:
data["ci_run_id_at_start"] = ci_run_id
STATE_FILE.write_text(json.dumps(data, indent=2))
STATE_FILE.chmod(0o600)
def _clear_state() -> None:
STATE_FILE.unlink(missing_ok=True)
def _update_heartbeat() -> None:
"""Record that the agent loop ran right now."""
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
HEARTBEAT_FILE.chmod(0o600)
def _find_session_uuid(session_name: str) -> str | None:
"""Return the Claude session UUID for *session_name*, or None if not found.
Claude stores session metadata in JSONL files; the first entry with
type=="agent-name" contains both the human-readable name and the UUID
needed for ``claude --resume <uuid>``.
"""
if not CLAUDE_PROJECTS_DIR.exists():
return None
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
try:
with jsonl.open() as fh:
for line in fh:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
return d.get("sessionId")
except Exception:
continue
return None
# ── agent launcher ────────────────────────────────────────────────────────────
def _start_agent(prompt: str, session_name: str) -> int:
"""Start Claude Code as a detached background process and return its PID."""
log_dir = Path.home() / ".sharedinbox-agent-logs"
log_dir.mkdir(mode=0o700, exist_ok=True)
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
log_file = log_dir / f"{session_name}-{ts}.log"
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
proc = subprocess.Popen(
[
"claude",
"--dangerously-skip-permissions",
"--name", session_name,
"-p", prompt,
],
stdin=subprocess.PIPE,
stdout=log_fh,
stderr=log_fh,
start_new_session=True,
)
log_fh.close() # Parent closes its copy; the child retains the fd.
# Answer the workspace-trust dialog; after this the pipe hits EOF.
proc.stdin.write(b"\n")
proc.stdin.close()
print(f"Started agent pid={proc.pid}, log={log_file}")
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
return proc.pid
def _agent_alive(state: dict) -> bool:
"""Return True if the agent process is still running."""
pid = state.get("pid")
if pid is None:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _is_claude_process(pid: int) -> bool:
"""Return True if pid's comm name indicates it is a claude/node process."""
try:
comm = Path(f"/proc/{pid}/comm").read_text().strip()
return comm in ("claude", "node")
except OSError:
return False
def _agent_age_seconds(state: dict) -> float:
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
try:
started_at = datetime.fromisoformat(state["started_at"])
return (datetime.now(timezone.utc) - started_at).total_seconds()
except Exception:
return 0.0
def _git_summary() -> str:
"""Return a one-line summary of the latest commit and whether it's been pushed."""
try:
commit = subprocess.run(
["git", "log", "--oneline", "-1"],
capture_output=True, text=True, check=True,
).stdout.strip()
ahead = subprocess.run(
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
capture_output=True, text=True,
)
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
elif ahead.returncode == 0:
push_status = "pushed"
else:
push_status = "no upstream"
return f"{commit} [{push_status}]"
except Exception:
return ""
def _kill_agent(state: dict) -> None:
"""Forcefully stop the running agent."""
pid = state.get("pid")
if pid and _is_claude_process(pid):
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
elif pid:
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
# ── subcommands ───────────────────────────────────────────────────────────────
def cmd_list() -> int:
"""List recent agent-loop sessions, newest first."""
if not CLAUDE_PROJECTS_DIR.exists():
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
return 0
sessions = []
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
agent_name = None
session_id = None
try:
with jsonl.open() as fh:
for line in fh:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("type") == "agent-name":
agent_name = d.get("agentName")
session_id = d.get("sessionId")
break
except Exception:
continue
if agent_name:
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
if not sessions:
print("No agent sessions found.")
return 0
sessions.sort(reverse=True)
total = len(sessions)
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
print(f" {'-'*16} {'-'*20} {'-'*36}")
for mtime, name, sid in sessions[:20]:
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
print(f" {ts:<16} {name:<20} {sid}")
if total > 20:
print(f" ... ({total - 20} more)")
return 0
# ── monitor subcommand ────────────────────────────────────────────────────────
def cmd_monitor() -> int:
"""Check that the agent loop has run within the last 2 hours.
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
Intended to be called from a scheduled CI job or cron every 2 hours.
"""
if not HEARTBEAT_FILE.exists():
print(
f"WARNING: Agent loop heartbeat file missing — "
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
)
return 1
try:
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
except ValueError:
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
return 1
age = (datetime.now(timezone.utc) - last_run).total_seconds()
if age > MAX_HEARTBEAT_AGE_SECONDS:
print(
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
)
return 1
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
return 0
# ── main flow ─────────────────────────────────────────────────────────────────
def _run_loop() -> int:
now = datetime.now(timezone.utc)
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
_update_heartbeat()
state = _read_state()
# ── 1. Agent already running? ─────────────────────────────────────────────
if state and _agent_alive(state):
age = _agent_age_seconds(state)
issue = state.get("issue")
kind = state.get("type", "issue")
pid = state.get("pid", "?")
issue_title = state.get("issue_title", "")
issue_ref = (
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
)
if age > MAX_AGENT_AGE_SECONDS:
print(
f"Agent pid={pid!r} ({issue_ref}) "
f"has been running for {age/60:.0f} min — aborting."
)
_kill_agent(state)
_clear_state()
if issue:
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue,
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
"Please investigate and resume manually.",
)
print(f"Set {_issue_url(issue)} to State/Question.")
return 1
session_name = state.get("session_name")
uuid = _find_session_uuid(session_name) if session_name else None
if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
elif session_name:
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
else:
resume_cmd = ""
git_info = _git_summary()
parts = [
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
]
if resume_cmd:
parts.append(f" Resume: {resume_cmd}")
if git_info:
parts.append(f" Commit: {git_info}")
print("\n".join(parts))
return 0
# Agent not running (or no state) — extract any pending issue, then clean up.
pending_issue: int | None = None
ci_run_id_at_start: int | None = None
if state:
pending_issue = state.get("issue")
ci_run_id_at_start = state.get("ci_run_id_at_start")
_clear_state()
# ── 2. Check for a PR opened by the agent ────────────────────────────────
if pending_issue:
branch = f"issue-{pending_issue}-fix"
pr = _find_pr_for_branch(branch)
if pr:
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
pr_run = _latest_ci_run_for_branch(branch)
if pr_run and pr_run.get("status") == "running":
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
_write_state(None, pending_issue, "pending-ci")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
prompt = (
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
f"(PR #{pr_number}). "
f"CI run: {_ci_run_url(pr_run['id'])}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push to the same branch. "
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
"issue via a commit message would be a bug. "
"Verify locally with 'task check' before pushing. "
"When done, stop."
)
session_name = f"ci-fix-pr-{pr_number}"
pid = _start_agent(prompt, session_name)
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
return 0
if not pr_run:
# No CI run yet — might be that CI hasn't triggered yet.
# Wait up to 15 min before giving up.
pr_created_at = pr.get("created_at", "")
try:
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
age_s = (datetime.now(timezone.utc) - created).total_seconds()
except Exception:
age_s = 999999
if age_s < 900:
print(
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
)
_write_state(None, pending_issue, "pending-ci")
return 0
print(
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
"agent may not have pushed. Setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
"Please investigate and resume manually.",
)
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}.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
)
return 0
if _find_pr_for_branch(branch):
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
"merge command). Please merge manually.",
)
return 0
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
# No open PR — check if it was already merged.
merged_pr = _find_pr_for_branch(branch, state="closed")
if merged_pr and merged_pr.get("merged"):
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
_close_issue(pending_issue)
return 0
# No open or merged PR — the agent may not have created one, or it was
# closed without merging (the bug this block was added to catch).
print(
f"No open or merged PR found for branch {branch!r} "
f"(issue #{pending_issue}) — setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
"Please investigate and resume manually.",
)
return 0
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
# This handles PRs whose CI has passed but were never merged because the
# state file was cleared (loop restart, killed agent, manual intervention).
open_prs = _open_issue_prs()
for pr in open_prs:
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
issue_num = int(m.group(1)) if m else None
pr_run = _latest_ci_run_for_pr(pr_number)
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
_write_state(None, issue_num, "pending-ci")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
continue
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
continue
# Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch):
print(
f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry."
)
if issue_num:
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue_num,
f"Automatic merge of PR #{pr_number} failed (PR is still open "
"after the merge command). Please merge manually.",
)
continue
if issue_num:
_close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
else:
print(f"Merged PR #{pr_number}.")
return 0
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
if pending_issue:
_write_state(None, pending_issue, "pending-ci")
return 0
if run and run.get("status") in ("failure", "error"):
# Guard: if the same main CI run has been failing since the last ci-fix
# agent started, that agent pushed to a branch instead of main. Before
# spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so.
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
in_flight = [
r for r in (check or {}).get("workflow_runs", [])
if r.get("status") == "running"
]
if in_flight:
print(
f"Main CI still shows the same failed run {run['id']}; "
f"{_ci_run_url(in_flight[0]['id'])} is running "
"(previous ci-fix pushed to a branch). Waiting."
)
return 0
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
prompt = (
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
f"The CI run ID is {run['id']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push directly to main. "
"Verify locally with 'task check' before pushing. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
"Do NOT close any issues. "
"When done, stop."
)
pid = _start_agent(prompt, "ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
ci_run_id=run["id"] if run else None)
return 0
# CI is ok (or no run).
if pending_issue:
latest_run_id = run["id"] if run else None
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
# CI run hasn't changed since the agent was launched → agent pushed nothing
# (likely crashed or hit a rate limit).
print(
f"No new CI run since agent started for {_issue_url(pending_issue)} "
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
"The agent exited without pushing any changes (no new CI run was triggered). "
"This usually means the agent hit a rate limit or crashed at startup. "
"The issue has been set to State/Question — please review the agent log and retry.",
)
return 0
_close_issue(pending_issue)
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
return 0
# Find a Ready issue.
issues = _ready_issues()
if not issues:
print("No issues with State/Ready. Nothing to do.")
return 0
issue = issues[0]
issue_number = issue["number"]
issue_title = issue["title"]
issue_body = issue.get("body", "")
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
# Mark InProgress before starting so the next cron tick sees it even if
# the agent hasn't had time to do so yet.
_set_labels(
issue_number,
add=[LABEL_IN_PROGRESS],
remove=[LABEL_READY],
)
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
Issue title: {issue_title}
Issue body:
{issue_body}
Instructions:
- Understand the issue thoroughly before writing any code.
- Implement the required change, following the existing code style.
- Write or update tests as appropriate.
- Run 'task check' locally and fix any failures before committing.
- Commit with a descriptive message and include (#{issue_number}) in the title,
e.g. "feat: description (#{issue_number})".
Do NOT use "Closes #N" or "Fixes #N" keywords the loop closes the issue
after CI passes; using those keywords would close it prematurely or wrongly.
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
git checkout -b issue-{issue_number}-fix
git push -u origin issue-{issue_number}-fix
fgj pr create --title "fix: <short description> (#{issue_number})" \\
--head issue-{issue_number}-fix --base main --repo {REPO}
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR the loop handles that after CI passes.
- If you hit a blocker you cannot resolve, set the issue label to State/Question
and stop (do NOT close the issue).
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
"""
session_name = f"issue-{issue_number}"
pid = _start_agent(prompt, session_name)
current_run_id = run["id"] if run else None
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="agent_loop")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("list", help="List recent agent sessions")
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
args = parser.parse_args()
if args.cmd == "list":
return cmd_list()
if args.cmd == "monitor":
return cmd_monitor()
return _run_loop()
if __name__ == "__main__":
sys.exit(main())
-7
View File
@@ -11,7 +11,6 @@ const _minCoveragePercent = 80;
// Pure-abstract interfaces: no executable code, Dart VM never instruments them. // Pure-abstract interfaces: no executable code, Dart VM never instruments them.
const _noCode = { const _noCode = {
'lib/core/db_schema_version.dart',
'lib/core/repositories/account_repository.dart', 'lib/core/repositories/account_repository.dart',
'lib/core/repositories/draft_repository.dart', 'lib/core/repositories/draft_repository.dart',
'lib/core/repositories/email_repository.dart', 'lib/core/repositories/email_repository.dart',
@@ -20,9 +19,7 @@ const _noCode = {
'lib/core/repositories/sync_log_repository.dart', 'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart', 'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart', 'lib/core/repositories/search_history_repository.dart',
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart', 'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/storage/secure_storage.dart', 'lib/core/storage/secure_storage.dart',
}; };
@@ -60,8 +57,6 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart', 'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart', 'lib/core/sync/background_sync.dart',
@@ -75,8 +70,6 @@ const _excluded = {
'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart', 'lib/data/repositories/search_history_repository_impl.dart',
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart', 'lib/core/services/update_service.dart',
}; };
+89 -63
View File
@@ -1,76 +1,102 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
set -euo pipefail set -euo pipefail
if [ -z "${SOPS_AGE_KEY:-}" ]; then if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set." echo "Error: DAGGER_STUNNEL_URL must be set."
exit 1 exit 1
fi fi
echo "Decrypting secrets with SOPS..." # Parse host and port (e.g., example.com:8774 or just example.com)
export SOPS_AGE_KEY="$SOPS_AGE_KEY" host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
SECRETS_JSON=$(mktemp) port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
trap "rm -f $SECRETS_JSON" EXIT if [ "$host" == "$port" ]; then
port="8774"
fi
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON" MAX_PROBE_ATTEMPTS=5
PROBE_DELAY=30
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON") for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON") echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
# Export all CI secrets to the GitHub Actions environment so subsequent steps echo "Found active server on $host:$port"
# can use them without referencing Forgejo secrets directly. break
export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
if [ -n "${GITHUB_ENV:-}" ]; then
# Use heredoc syntax for multiline-safe export
{
printf '%s<<__EOF__\n' "$name"
printf '%s\n' "$value"
printf '__EOF__\n'
} >> "$GITHUB_ENV"
fi fi
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}" 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
export_secret "SSH_PRIVATE_KEY" # 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
export_secret "SSH_KNOWN_HOSTS" echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
export_secret "SSH_USER" if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
export_secret "SSH_HOST" _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
export_secret "WEBSITE_SSH_HOST" timeout 8 dagger version >/dev/null 2>&1; then
export_secret "PLAY_STORE_CONFIG_JSON" echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
export_secret "ANDROID_KEYSTORE_BASE64" if [ -n "${GITHUB_ENV:-}" ]; then
export_secret "ANDROID_KEYSTORE_PASSWORD" echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY" echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
export_secret "RENOVATE_FORGEJO_TOKEN" else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
# Setup SSH directory and keys export _DAGGER_RUNNER_HOST="tcp://$host:$port"
mkdir -p ~/.ssh echo "Dagger configured at tcp://$host:$port (plain TCP)"
chmod 700 ~/.ssh fi
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key exit 0
chmod 600 ~/.ssh/dagger_key
# Add remote host to known_hosts
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
fi fi
echo "Plain TCP connection not available; trying TLS stunnel..."
# Verify the connection # 2b. Setup TLS credentials (passed as env vars from secrets)
echo "Verifying connection to Dagger engine via SSH tunnel..." mkdir -p /tmp/dagger-tls
# Use a simple command that doesn't require complex GraphQL operations. echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
echo "Error: Dagger engine unreachable via tunnel at localhost:8080" echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
# Debug chmod 600 /tmp/dagger-tls/client.key
ps aux | grep ssh
# 3. Configure and start stunnel
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
cat << EOF > "$STUNNEL_CONF"
client = yes
foreground = yes
pid = /tmp/stunnel.pid
debug = warning
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
socket = r:SO_KEEPALIVE=1
socket = r:TCP_KEEPIDLE=10
socket = r:TCP_KEEPINTVL=5
socket = r:TCP_KEEPCNT=3
[dagger]
accept = 127.0.0.1:1774
connect = $host:$port
CAfile = /tmp/dagger-tls/ca.crt
cert = /tmp/dagger-tls/client.crt
key = /tmp/dagger-tls/client.key
verifyChain = yes
EOF
# Start stunnel in the background
stunnel "$STUNNEL_CONF" &
TUNNEL_PID=$!
# Give it a moment to establish
sleep 2
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
echo "Error: stunnel failed to start"
exit 1 exit 1
fi fi
echo "Dagger connection verified successfully."
# 4. Export environment for subsequent CI steps
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
echo "Tunnel established. Dagger is configured to use the remote engine."
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
fi
+802
View File
@@ -0,0 +1,802 @@
#!/usr/bin/env python3
"""Tests for agent_loop.py."""
import contextlib
import io
import json
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import sys
sys.path.insert(0, str(Path(__file__).parent))
import agent_loop
class TestUrlHelpers(unittest.TestCase):
def test_issue_url(self):
url = agent_loop._issue_url(128)
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
def test_ci_run_url(self):
url = agent_loop._ci_run_url(4145144)
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
class TestStateFile(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
self._tmp.close()
self._orig = agent_loop.STATE_FILE
agent_loop.STATE_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no state file.
def tearDown(self):
agent_loop.STATE_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_write_state_stores_pid(self):
agent_loop._write_state(12345, 91, "issue")
data = json.loads(Path(self._tmp.name).read_text())
self.assertEqual(data["pid"], 12345)
self.assertNotIn("tmux_session", data)
def test_write_state_stores_issue_and_kind(self):
agent_loop._write_state(99, 7, "ci-fix")
data = json.loads(Path(self._tmp.name).read_text())
self.assertEqual(data["issue"], 7)
self.assertEqual(data["type"], "ci-fix")
self.assertIn("started_at", data)
def test_read_state_returns_none_when_missing(self):
self.assertIsNone(agent_loop._read_state())
def test_read_and_write_roundtrip(self):
agent_loop._write_state(42, 10, "issue")
state = agent_loop._read_state()
self.assertIsNotNone(state)
self.assertEqual(state["pid"], 42)
self.assertEqual(state["issue"], 10)
def test_clear_state_removes_file(self):
agent_loop._write_state(1, None, "ci-fix")
agent_loop._clear_state()
self.assertIsNone(agent_loop._read_state())
def test_write_state_stores_issue_title(self):
agent_loop._write_state(42, 10, "issue", "My Test Issue")
data = json.loads(Path(self._tmp.name).read_text())
self.assertEqual(data["issue_title"], "My Test Issue")
def test_write_state_omits_issue_title_when_none(self):
agent_loop._write_state(42, None, "ci-fix")
data = json.loads(Path(self._tmp.name).read_text())
self.assertNotIn("issue_title", data)
class TestAgentAlive(unittest.TestCase):
def test_own_pid_is_alive(self):
self.assertTrue(agent_loop._agent_alive({"pid": os.getpid()}))
def test_nonexistent_pid_is_dead(self):
self.assertFalse(agent_loop._agent_alive({"pid": 999999999}))
def test_missing_pid_returns_false(self):
self.assertFalse(agent_loop._agent_alive({}))
self.assertFalse(agent_loop._agent_alive({"pid": None}))
class TestIsClaudeProcess(unittest.TestCase):
def test_returns_true_for_claude_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_true_for_node_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_false_for_other_process(self):
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
self.assertFalse(agent_loop._is_claude_process(1234))
def test_returns_false_when_proc_missing(self):
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
self.assertFalse(agent_loop._is_claude_process(1234))
class TestKillAgent(unittest.TestCase):
def test_kill_sends_sigkill(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
def test_kill_ignores_missing_process(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
def test_kill_noop_when_no_pid(self):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({})
mock_kill.assert_not_called()
def test_kill_skips_recycled_pid(self):
with patch("agent_loop._is_claude_process", return_value=False):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_not_called()
class TestStartAgent(unittest.TestCase):
def _make_mock_proc(self, pid=42):
proc = MagicMock()
proc.pid = pid
proc.stdin = io.BytesIO()
return proc
def test_start_agent_returns_pid(self):
mock_proc = self._make_mock_proc(pid=42)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc):
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
result = agent_loop._start_agent("do something", "issue-99")
self.assertEqual(result, 42)
def test_start_agent_uses_popen_not_tmux(self):
mock_proc = self._make_mock_proc(pid=7)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
with patch("agent_loop.subprocess.run") as mock_run:
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
agent_loop._start_agent("prompt", "ci-fix")
mock_popen.assert_called_once()
mock_run.assert_not_called()
def test_start_agent_passes_session_name_to_claude(self):
mock_proc = self._make_mock_proc(pid=7)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
agent_loop._start_agent("prompt", "issue-55")
cmd = mock_popen.call_args[0][0]
self.assertIn("issue-55", cmd)
self.assertIn("claude", cmd[0])
def test_start_agent_uses_start_new_session(self):
mock_proc = self._make_mock_proc(pid=7)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
agent_loop._start_agent("prompt", "issue-55")
kwargs = mock_popen.call_args[1]
self.assertTrue(kwargs.get("start_new_session"))
class TestMain(unittest.TestCase):
"""Tests for the main() flow."""
def _make_mock_proc(self, pid=42):
proc = MagicMock()
proc.pid = pid
proc.stdin = io.BytesIO()
return proc
def _make_issue(self, number=10, title="Do something"):
return {"number": number, "title": title, "body": "", "labels": []}
def test_sets_in_progress_before_starting_agent(self):
"""_set_labels(InProgress) must be called before _start_agent."""
call_order = []
mock_proc = self._make_mock_proc(pid=55)
def fake_set_labels(issue, add, remove):
call_order.append(("set_labels", add, remove))
def fake_start_agent(prompt, session_name):
call_order.append(("start_agent", session_name))
return 55
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
patch("agent_loop._write_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
labels_idx = next(
i for i, c in enumerate(call_order) if c[0] == "set_labels"
)
agent_idx = next(
i for i, c in enumerate(call_order) if c[0] == "start_agent"
)
self.assertLess(labels_idx, agent_idx,
"_set_labels must be called before _start_agent")
def test_sets_in_progress_label_and_removes_ready(self):
"""The InProgress label is added and the Ready label is removed."""
captured = {}
def fake_set_labels(issue, add, remove):
captured["add"] = add
captured["remove"] = remove
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", return_value=99), \
patch("agent_loop._write_state"):
agent_loop._run_loop()
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
def test_no_ready_issues_does_nothing(self):
"""main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._start_agent") as mock_start:
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_labels.assert_not_called()
mock_start.assert_not_called()
def test_prompt_does_not_tell_agent_to_close_issue(self):
"""Agents must not close issues; the loop handles closing after CI passes."""
captured_prompt = {}
def fake_start_agent(prompt, session_name):
captured_prompt["prompt"] = prompt
return 77
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
patch("agent_loop._write_state"):
agent_loop._run_loop()
prompt = captured_prompt.get("prompt", "")
# "do NOT close the issue" (blocker instruction) is fine; what must be
# absent is any affirmative instruction to close on completion.
self.assertNotIn("close the issue and stop", prompt.lower())
class TestPendingCi(unittest.TestCase):
"""Tests for the pending-CI state: issue closed only after CI passes."""
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
return {
"pid": 999999999, # non-existent PID
"issue": issue,
"started_at": "2026-01-01T00:00:00+00:00",
"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."""
# First call: PR found open. Second call (post-merge verification): PR closed.
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._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._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
def test_already_merged_pr_closes_issue_without_ci_url(self):
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
def find_pr(branch, state="open"):
if state == "closed":
return {"number": 5, "merged": True}
return None
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
result = agent_loop._run_loop()
output = buf.getvalue()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
self.assertIn("already merged", output)
self.assertNotIn("/actions/runs/", output)
def test_no_pr_found_sets_question_label(self):
"""When no open or merged PR exists for the pending branch, set State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
mock_labels.assert_called_once_with(
10,
add=[agent_loop.LABEL_QUESTION],
remove=[agent_loop.LABEL_IN_PROGRESS],
)
mock_comment.assert_called_once()
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
def test_does_not_close_issue_when_ci_fails(self):
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._start_agent", return_value=55), \
patch("agent_loop._write_state"), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
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."""
written = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written["pid"] = pid
written["issue"] = issue
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._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
self.assertEqual(written.get("issue"), 10)
self.assertEqual(written.get("kind"), "pending-ci")
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."""
written = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written["pid"] = pid
written["issue"] = issue
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._start_agent", return_value=55), \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
self.assertEqual(written.get("issue"), 10)
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."""
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._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):
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
with patch("agent_loop._read_state", return_value={
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
"type": "ci-fix",
}), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
class TestOutputFormat(unittest.TestCase):
"""Verify output format: no [agent_loop] prefix, URLs in output."""
def test_output_starts_with_header(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
first_line = buf.getvalue().splitlines()[0]
self.assertTrue(first_line.startswith("---------------------- Starting "),
f"Unexpected first line: {first_line!r}")
def test_no_agent_loop_prefix_in_output(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertNotIn("[agent_loop]", buf.getvalue())
def test_ci_run_output_contains_url(self):
run = {"id": 4145144, "status": "running"}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
buf.getvalue())
def test_issue_output_contains_url_and_title(self):
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", return_value=99), \
patch("agent_loop._write_state"), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
self.assertIn("Fix something", output)
class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
def test_skips_schedule_runs_returns_push_to_main(self):
runs = [
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_returns_none_when_only_schedule_runs_exist(self):
runs = [
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_push_to_main_run(self):
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 42)
class TestLatestCiRunForBranch(unittest.TestCase):
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
def _mock_tea_runs(self, runs):
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
yield m
def test_pr_event_matches_via_event_payload(self):
run = self._make_pr_run("issue-166-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
self.assertEqual(result["id"], 1)
def test_pr_event_does_not_match_wrong_branch(self):
run = self._make_pr_run("issue-99-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_push_event_matches_via_prettyref(self):
run = self._make_push_run("issue-166-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_head_branch_field_absent_still_works(self):
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
run = self._make_pr_run("issue-166-fix")
self.assertNotIn("head_branch", run)
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
def test_returns_none_when_no_runs(self):
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_returns_first_matching_run(self):
runs = [
self._make_pr_run("issue-166-fix", status="success"),
self._make_pr_run("issue-166-fix", status="failure"),
]
runs[0]["id"] = 10
runs[1]["id"] = 11
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertEqual(result["id"], 10)
class TestFindSessionUuid(unittest.TestCase):
"""Tests for _find_session_uuid()."""
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
path = directory / filename
with path.open("w") as fh:
for entry in entries:
fh.write(json.dumps(entry) + "\n")
return path
def test_returns_uuid_for_matching_session_name(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertEqual(result, "uuid-abc-123")
def test_returns_none_when_name_does_not_match(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_returns_none_when_directory_missing(self):
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_returns_none_when_no_agent_name_entry(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "message", "content": "hello"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_scans_multiple_files_to_find_match(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "aaa.jsonl", [
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
])
self._write_jsonl(projects_dir, "bbb.jsonl", [
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertEqual(result, "uuid-91")
class TestRunLoopResumeCommand(unittest.TestCase):
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
def _alive_state(self, session_name="issue-91"):
return {
"pid": os.getpid(), # own PID is always alive
"issue": 91,
"started_at": "2026-05-23T12:00:00+00:00",
"type": "issue",
"session_name": session_name,
}
def test_resume_shows_uuid_when_found(self):
buf = io.StringIO()
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
with patch("agent_loop._read_state", return_value=self._alive_state()), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output)
def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._alive_state()), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=None), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("scripts/agent_loop.py list", output)
# Must NOT show the session name as a valid resume argument.
self.assertNotIn("claude --resume issue-91", output)
def test_resume_not_shown_when_no_session_name(self):
state = self._alive_state()
del state["session_name"]
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=state), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=None), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertNotIn("Resume:", output)
class TestHeartbeat(unittest.TestCase):
"""Tests for _update_heartbeat() and cmd_monitor()."""
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
self._tmp.close()
self._orig = agent_loop.HEARTBEAT_FILE
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no heartbeat file.
def tearDown(self):
agent_loop.HEARTBEAT_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_update_heartbeat_writes_timestamp(self):
agent_loop._update_heartbeat()
content = Path(self._tmp.name).read_text().strip()
dt = datetime.fromisoformat(content)
age = (datetime.now(timezone.utc) - dt).total_seconds()
self.assertLess(age, 5)
def test_update_heartbeat_creates_file(self):
self.assertFalse(Path(self._tmp.name).exists())
agent_loop._update_heartbeat()
self.assertTrue(Path(self._tmp.name).exists())
def test_monitor_healthy_when_recent(self):
agent_loop._update_heartbeat()
result = agent_loop.cmd_monitor()
self.assertEqual(result, 0)
def test_monitor_warns_when_heartbeat_missing(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_stale(self):
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
Path(self._tmp.name).write_text(stale)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_corrupted(self):
Path(self._tmp.name).write_text("not-a-timestamp")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_run_loop_updates_heartbeat(self):
self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop()
self.assertTrue(Path(self._tmp.name).exists())
if __name__ == "__main__":
unittest.main()
-33
View File
File diff suppressed because one or more lines are too long
+49 -67
View File
@@ -20,67 +20,63 @@ Future<imap.ImapClient> _fakeImapConnect(
throw const SocketException('fake — no real IMAP server in tests'); throw const SocketException('fake — no real IMAP server in tests');
void main() { void main() {
test( test('AccountSyncManager schedules IMAP sync for multiple accounts',
'AccountSyncManager schedules IMAP sync for multiple accounts', () async {
() async { final accounts = _FakeAccounts('pw');
final accounts = _FakeAccounts('pw'); final mailboxes = _FakeMailboxes();
final mailboxes = _FakeMailboxes(); final emails = _FakeEmails();
final emails = _FakeEmails(); final logs = _FakeLogs();
final logs = _FakeLogs();
final manager = AccountSyncManager( final manager = AccountSyncManager(
accounts, accounts,
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
imapConnect: _fakeImapConnect, imapConnect: _fakeImapConnect,
); );
final a1 = _account('1'); final a1 = _account('1');
final a2 = _account('2'); final a2 = _account('2');
manager.start(); manager.start();
accounts.push([a1, a2]); accounts.push([a1, a2]);
// Allow some time for listeners to fire. // Allow some time for listeners to fire.
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose(); manager.dispose();
}, });
);
test( test('AccountSyncManager schedules JMAP sync for multiple accounts',
'AccountSyncManager schedules JMAP sync for multiple accounts', () async {
() async { final accounts = _FakeAccounts('pw');
final accounts = _FakeAccounts('pw'); final mailboxes = _FakeMailboxes();
final mailboxes = _FakeMailboxes(); final emails = _FakeEmails();
final emails = _FakeEmails(); final logs = _FakeLogs();
final logs = _FakeLogs();
final manager = AccountSyncManager( final manager = AccountSyncManager(
accounts, accounts,
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
); );
final a1 = _jmapAccount('1'); final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2'); final a2 = _jmapAccount('2');
manager.start(); manager.start();
accounts.push([a1, a2]); accounts.push([a1, a2]);
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose(); manager.dispose();
}, });
);
} }
Account _account(String id) => Account( Account _account(String id) => Account(
@@ -153,29 +149,17 @@ class _FakeMailboxes implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{}; final syncCounts = <String, int>{};
@override @override
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) => Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
@@ -304,8 +288,6 @@ class _FakeLogs implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+49 -51
View File
@@ -566,61 +566,59 @@ void main() {
expect(pending.first.changeType, 'delete'); expect(pending.first.changeType, 'delete');
}); });
test( test('downloadAttachment fetches binary attachment bytes from IMAP',
'downloadAttachment fetches binary attachment bytes from IMAP', () async {
() async { final attachmentBytes = Uint8List.fromList(
final attachmentBytes = Uint8List.fromList( List.generate(32, (i) => i + 1),
List.generate(32, (i) => i + 1), );
const attachmentName = 'hello.bin';
const attachmentMime = 'application/octet-stream';
// Build a multipart email with a binary attachment and append it.
final client = await _imapConnect(
host: imapHost,
port: imapPort,
user: userEmail,
pass: userPass,
);
try {
final builder = MessageBuilder()
..from = [MailAddress('Alice', userEmail)]
..to = [MailAddress('Alice', userEmail)]
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
..text = 'See attachment.';
builder.addBinary(
attachmentBytes,
MediaType.fromText(attachmentMime),
filename: attachmentName,
); );
const attachmentName = 'hello.bin'; await client.appendMessage(
const attachmentMime = 'application/octet-stream'; builder.buildMimeMessage(),
targetMailboxPath: 'INBOX',
// Build a multipart email with a binary attachment and append it.
final client = await _imapConnect(
host: imapHost,
port: imapPort,
user: userEmail,
pass: userPass,
); );
try { } finally {
final builder = MessageBuilder() await client.logout();
..from = [MailAddress('Alice', userEmail)] }
..to = [MailAddress('Alice', userEmail)]
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
..text = 'See attachment.';
builder.addBinary(
attachmentBytes,
MediaType.fromText(attachmentMime),
filename: attachmentName,
);
await client.appendMessage(
builder.buildMimeMessage(),
targetMailboxPath: 'INBOX',
);
} finally {
await client.logout();
}
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first; final emails = await r.emails.observeEmails('test', 'INBOX').first;
expect(emails, hasLength(1)); expect(emails, hasLength(1));
expect(emails.first.hasAttachment, isTrue); expect(emails.first.hasAttachment, isTrue);
final body = await r.emails.getEmailBody(emails.first.id); final body = await r.emails.getEmailBody(emails.first.id);
expect(body.attachments, hasLength(1)); expect(body.attachments, hasLength(1));
expect(body.attachments.first.filename, attachmentName); expect(body.attachments.first.filename, attachmentName);
expect(body.attachments.first.contentType, attachmentMime); expect(body.attachments.first.contentType, attachmentMime);
expect(body.attachments.first.fetchPartId, isNotEmpty); expect(body.attachments.first.fetchPartId, isNotEmpty);
final path = await r.emails.downloadAttachment( final path = await r.emails.downloadAttachment(
emails.first.id, emails.first.id,
body.attachments.first, body.attachments.first,
); );
final downloaded = await File(path).readAsBytes(); final downloaded = await File(path).readAsBytes();
expect(downloaded, equals(attachmentBytes)); expect(downloaded, equals(attachmentBytes));
}, });
);
} }
@@ -73,15 +73,13 @@ abstract class AccountRepositoryContract {
expect(await repo.getPassword(_a.id), 'new'); expect(await repo.getPassword(_a.id), 'new');
}); });
test( test('removeAccount makes account disappear from observeAccounts',
'removeAccount makes account disappear from observeAccounts', () async {
() async { final repo = makeRepo();
final repo = makeRepo(); await repo.addAccount(_a, 'pw');
await repo.addAccount(_a, 'pw'); await repo.removeAccount(_a.id);
await repo.removeAccount(_a.id); expect(await repo.observeAccounts().first, isEmpty);
expect(await repo.observeAccounts().first, isEmpty); });
},
);
test('getAccount returns null after removeAccount', () async { test('getAccount returns null after removeAccount', () async {
final repo = makeRepo(); final repo = makeRepo();
+27 -41
View File
@@ -37,41 +37,44 @@ void main() {
// MissingPluginException (channel unavailable on the device), the IMAP sync // MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff. // loop must stop permanently instead of retrying indefinitely with backoff.
test( test(
'MissingPluginException from secure storage stops IMAP sync loop permanently', 'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async { () async {
final syncLog = FakeSyncLogRepository(); final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager( final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(), _AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(), FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(), FakeEmailRepository(),
syncLog: syncLog, syncLog: syncLog,
); );
m.start(); m.start();
// Allow the first sync cycle to run and fail. // Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1)); expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse); expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently. // Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1'); m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries. // Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry. // After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1)); expect(syncLog.logs, hasLength(1));
m.dispose(); m.dispose();
}, });
);
} }
class FakeEmailRepository implements EmailRepository { class FakeEmailRepository implements EmailRepository {
@override @override
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) => Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
@@ -178,8 +181,6 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -221,21 +222,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<Mailbox?> findMailboxByRole(String id, String role) async => null; Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _AccountRepositoryWithMissingPlugin implements AccountRepository { class _AccountRepositoryWithMissingPlugin implements AccountRepository {
+157 -195
View File
@@ -3,16 +3,16 @@
// Do not manually edit this file. // Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i5; import 'dart:async' as _i4;
import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i7; import 'package:mockito/src/dummies.dart' as _i6;
import 'package:sharedinbox/core/models/account.dart' as _i6; import 'package:sharedinbox/core/models/account.dart' as _i5;
import 'package:sharedinbox/core/models/email.dart' as _i3; import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/models/mailbox.dart' as _i2; import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4; import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9; import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8; import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_redundant_argument_values
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
// ignore_for_file: subtype_of_sealed_class // ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member // ignore_for_file: invalid_use_of_internal_member
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox { class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
_FakeMailbox_0( _FakeEmailBody_0(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -39,8 +39,9 @@ class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
); );
} }
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody { class _FakeSyncEmailsResult_1 extends _i1.SmartFake
_FakeEmailBody_1( implements _i2.SyncEmailsResult {
_FakeSyncEmailsResult_1(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -49,20 +50,9 @@ class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
); );
} }
class _FakeSyncEmailsResult_2 extends _i1.SmartFake class _FakeReliabilityResult_2 extends _i1.SmartFake
implements _i3.SyncEmailsResult { implements _i2.ReliabilityResult {
_FakeSyncEmailsResult_2( _FakeReliabilityResult_2(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeReliabilityResult_3 extends _i1.SmartFake
implements _i3.ReliabilityResult {
_FakeReliabilityResult_3(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -74,32 +64,32 @@ class _FakeReliabilityResult_3 extends _i1.SmartFake
/// A class which mocks [AccountRepository]. /// A class which mocks [AccountRepository].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository { class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
MockAccountRepository() { MockAccountRepository() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod( _i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeAccounts, #observeAccounts,
[], [],
), ),
returnValue: _i5.Stream<List<_i6.Account>>.empty(), returnValue: _i4.Stream<List<_i5.Account>>.empty(),
) as _i5.Stream<List<_i6.Account>>); ) as _i4.Stream<List<_i5.Account>>);
@override @override
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod( _i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getAccount, #getAccount,
[id], [id],
), ),
returnValue: _i5.Future<_i6.Account?>.value(), returnValue: _i4.Future<_i5.Account?>.value(),
) as _i5.Future<_i6.Account?>); ) as _i4.Future<_i5.Account?>);
@override @override
_i5.Future<void> addAccount( _i4.Future<void> addAccount(
_i6.Account? account, _i5.Account? account,
String? password, String? password,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -110,13 +100,13 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
password, password,
], ],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<void> updateAccount( _i4.Future<void> updateAccount(
_i6.Account? account, { _i5.Account? account, {
String? password, String? password,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -125,65 +115,65 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
[account], [account],
{#password: password}, {#password: password},
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod( _i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#removeAccount, #removeAccount,
[id], [id],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod( _i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getPassword, #getPassword,
[accountId], [accountId],
), ),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>( returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#getPassword, #getPassword,
[accountId], [accountId],
), ),
)), )),
) as _i5.Future<String>); ) as _i4.Future<String>);
} }
/// A class which mocks [MailboxRepository]. /// A class which mocks [MailboxRepository].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository { class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
MockMailboxRepository() { MockMailboxRepository() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) => _i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeMailboxes, #observeMailboxes,
[accountId], [accountId],
), ),
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(), returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
) as _i5.Stream<List<_i2.Mailbox>>); ) as _i4.Stream<List<_i8.Mailbox>>);
@override @override
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod( _i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#syncMailboxes, #syncMailboxes,
[accountId], [accountId],
), ),
returnValue: _i5.Future<int>.value(0), returnValue: _i4.Future<int>.value(0),
) as _i5.Future<int>); ) as _i4.Future<int>);
@override @override
_i5.Future<_i2.Mailbox?> findMailboxByRole( _i4.Future<_i8.Mailbox?> findMailboxByRole(
String? accountId, String? accountId,
String? role, String? role,
) => ) =>
@@ -195,46 +185,18 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
role, role,
], ],
), ),
returnValue: _i5.Future<_i2.Mailbox?>.value(), returnValue: _i4.Future<_i8.Mailbox?>.value(),
) as _i5.Future<_i2.Mailbox?>); ) as _i4.Future<_i8.Mailbox?>);
@override @override
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod( _i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearForResync, #clearForResync,
[accountId], [accountId],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override
_i5.Future<_i2.Mailbox> createMailboxWithRole(
String? accountId,
String? name,
String? role,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
} }
/// A class which mocks [EmailRepository]. /// A class which mocks [EmailRepository].
@@ -246,13 +208,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
} }
@override @override
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod( _i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued), Invocation.getter(#onChangesQueued),
returnValue: _i5.Stream<String>.empty(), returnValue: _i4.Stream<String>.empty(),
) as _i5.Stream<String>); ) as _i4.Stream<String>);
@override @override
_i5.Stream<List<_i3.Email>> observeEmails( _i4.Stream<List<_i2.Email>> observeEmails(
String? accountId, String? accountId,
String? mailboxPath, { String? mailboxPath, {
int? limit = 50, int? limit = 50,
@@ -266,11 +228,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
{#limit: limit}, {#limit: limit},
), ),
returnValue: _i5.Stream<List<_i3.Email>>.empty(), returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>); ) as _i4.Stream<List<_i2.Email>>);
@override @override
_i5.Stream<List<_i3.EmailThread>> observeThreads( _i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId, String? accountId,
String? mailboxPath, { String? mailboxPath, {
int? limit = 50, int? limit = 50,
@@ -284,11 +246,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
{#limit: limit}, {#limit: limit},
), ),
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(), returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>); ) as _i4.Stream<List<_i2.EmailThread>>);
@override @override
_i5.Stream<List<_i3.Email>> observeEmailsInThread( _i4.Stream<List<_i2.Email>> observeEmailsInThread(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
String? threadId, String? threadId,
@@ -302,36 +264,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
threadId, threadId,
], ],
), ),
returnValue: _i5.Stream<List<_i3.Email>>.empty(), returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>); ) as _i4.Stream<List<_i2.Email>>);
@override @override
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod( _i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getEmail, #getEmail,
[emailId], [emailId],
), ),
returnValue: _i5.Future<_i3.Email?>.value(), returnValue: _i4.Future<_i2.Email?>.value(),
) as _i5.Future<_i3.Email?>); ) as _i4.Future<_i2.Email?>);
@override @override
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) => _i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#getEmailBody, #getEmailBody,
[emailId], [emailId],
), ),
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1( returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
this, this,
Invocation.method( Invocation.method(
#getEmailBody, #getEmailBody,
[emailId], [emailId],
), ),
)), )),
) as _i5.Future<_i3.EmailBody>); ) as _i4.Future<_i2.EmailBody>);
@override @override
_i5.Future<_i3.SyncEmailsResult> syncEmails( _i4.Future<_i2.SyncEmailsResult> syncEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -344,7 +306,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
returnValue: returnValue:
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2( _i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
this, this,
Invocation.method( Invocation.method(
#syncEmails, #syncEmails,
@@ -354,10 +316,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i5.Future<_i3.SyncEmailsResult>); ) as _i4.Future<_i2.SyncEmailsResult>);
@override @override
_i5.Future<void> setFlag( _i4.Future<void> setFlag(
String? emailId, { String? emailId, {
bool? seen, bool? seen,
bool? flagged, bool? flagged,
@@ -371,12 +333,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
#flagged: flagged, #flagged: flagged,
}, },
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<void> markAllAsRead( _i4.Future<void> markAllAsRead(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -388,12 +350,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
mailboxPath, mailboxPath,
], ],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<void> moveEmail( _i4.Future<void> moveEmail(
String? emailId, String? emailId,
String? destMailboxPath, String? destMailboxPath,
) => ) =>
@@ -405,23 +367,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
destMailboxPath, destMailboxPath,
], ],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod( _i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#deleteEmail, #deleteEmail,
[emailId], [emailId],
), ),
returnValue: _i5.Future<String?>.value(), returnValue: _i4.Future<String?>.value(),
) as _i5.Future<String?>); ) as _i4.Future<String?>);
@override @override
_i5.Future<void> sendEmail( _i4.Future<void> sendEmail(
String? accountId, String? accountId,
_i3.EmailDraft? draft, _i2.EmailDraft? draft,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -431,14 +393,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
draft, draft,
], ],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<String> downloadAttachment( _i4.Future<String> downloadAttachment(
String? emailId, String? emailId,
_i3.EmailAttachment? attachment, _i2.EmailAttachment? attachment,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -448,7 +410,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
attachment, attachment,
], ],
), ),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>( returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#downloadAttachment, #downloadAttachment,
@@ -458,25 +420,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i5.Future<String>); ) as _i4.Future<String>);
@override @override
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod( _i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#fetchRawRfc822, #fetchRawRfc822,
[emailId], [emailId],
), ),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>( returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#fetchRawRfc822, #fetchRawRfc822,
[emailId], [emailId],
), ),
)), )),
) as _i5.Future<String>); ) as _i4.Future<String>);
@override @override
_i5.Future<List<_i3.Email>> searchEmails( _i4.Future<List<_i2.Email>> searchEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
String? query, String? query,
@@ -490,11 +452,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query, query,
], ],
), ),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]), returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i5.Future<List<_i3.Email>>); ) as _i4.Future<List<_i2.Email>>);
@override @override
_i5.Future<List<_i3.Email>> searchEmailsGlobal( _i4.Future<List<_i2.Email>> searchEmailsGlobal(
String? accountId, String? accountId,
String? query, String? query,
) => ) =>
@@ -506,11 +468,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query, query,
], ],
), ),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]), returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i5.Future<List<_i3.Email>>); ) as _i4.Future<List<_i2.Email>>);
@override @override
_i5.Future<List<_i3.Email>> getEmailsByAddress( _i4.Future<List<_i2.Email>> getEmailsByAddress(
String? accountId, String? accountId,
String? address, String? address,
) => ) =>
@@ -522,11 +484,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
address, address,
], ],
), ),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]), returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i5.Future<List<_i3.Email>>); ) as _i4.Future<List<_i2.Email>>);
@override @override
_i5.Future<List<_i3.EmailAddress>> searchAddresses( _i4.Future<List<_i2.EmailAddress>> searchAddresses(
String? accountId, String? accountId,
String? query, { String? query, {
int? limit = 10, int? limit = 10,
@@ -541,11 +503,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
{#limit: limit}, {#limit: limit},
), ),
returnValue: returnValue:
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]), _i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
) as _i5.Future<List<_i3.EmailAddress>>); ) as _i4.Future<List<_i2.EmailAddress>>);
@override @override
_i5.Future<int> flushPendingChanges( _i4.Future<int> flushPendingChanges(
String? accountId, String? accountId,
String? password, String? password,
) => ) =>
@@ -557,42 +519,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password, password,
], ],
), ),
returnValue: _i5.Future<int>.value(0), returnValue: _i4.Future<int>.value(0),
) as _i5.Future<int>); ) as _i4.Future<int>);
@override @override
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations( _i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
String? accountId) => String? accountId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeFailedMutations, #observeFailedMutations,
[accountId], [accountId],
), ),
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(), returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
) as _i5.Stream<List<_i3.FailedMutation>>); ) as _i4.Stream<List<_i2.FailedMutation>>);
@override @override
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod( _i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#discardMutation, #discardMutation,
[id], [id],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod( _i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#retryMutation, #retryMutation,
[id], [id],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<bool> cancelPendingChange( _i4.Future<bool> cancelPendingChange(
String? emailId, String? emailId,
String? changeType, String? changeType,
) => ) =>
@@ -604,11 +566,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
changeType, changeType,
], ],
), ),
returnValue: _i5.Future<bool>.value(false), returnValue: _i4.Future<bool>.value(false),
) as _i5.Future<bool>); ) as _i4.Future<bool>);
@override @override
_i5.Future<void> snoozeEmail( _i4.Future<void> snoozeEmail(
String? emailId, String? emailId,
DateTime? until, DateTime? until,
) => ) =>
@@ -620,32 +582,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
until, until,
], ],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod( _i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#wakeUpEmails, #wakeUpEmails,
[accountId], [accountId],
), ),
returnValue: _i5.Future<int>.value(0), returnValue: _i4.Future<int>.value(0),
) as _i5.Future<int>); ) as _i4.Future<int>);
@override @override
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) => _i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#restoreEmails, #restoreEmails,
[emails], [emails],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
@override @override
_i5.Future<_i3.Email?> findEmailByMessageId( _i4.Future<_i2.Email?> findEmailByMessageId(
String? accountId, String? accountId,
String? messageId, String? messageId,
) => ) =>
@@ -657,20 +619,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
messageId, messageId,
], ],
), ),
returnValue: _i5.Future<_i3.Email?>.value(), returnValue: _i4.Future<_i2.Email?>.value(),
) as _i5.Future<_i3.Email?>); ) as _i4.Future<_i2.Email?>);
@override @override
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod( _i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#applySieveRules, #applySieveRules,
[accountId], [accountId],
), ),
returnValue: _i5.Future<int>.value(0), returnValue: _i4.Future<int>.value(0),
) as _i5.Future<int>); ) as _i4.Future<int>);
@override @override
_i5.Stream<void> watchJmapPush( _i4.Stream<void> watchJmapPush(
String? accountId, String? accountId,
String? password, String? password,
) => ) =>
@@ -682,11 +644,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password, password,
], ],
), ),
returnValue: _i5.Stream<void>.empty(), returnValue: _i4.Stream<void>.empty(),
) as _i5.Stream<void>); ) as _i4.Stream<void>);
@override @override
_i5.Future<_i3.ReliabilityResult> verifySyncReliability( _i4.Future<_i2.ReliabilityResult> verifySyncReliability(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -699,7 +661,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
returnValue: returnValue:
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3( _i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
this, this,
Invocation.method( Invocation.method(
#verifySyncReliability, #verifySyncReliability,
@@ -709,15 +671,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i5.Future<_i3.ReliabilityResult>); ) as _i4.Future<_i2.ReliabilityResult>);
@override @override
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod( _i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearForResync, #clearForResync,
[accountId], [accountId],
), ),
returnValue: _i5.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i5.Future<void>); ) as _i4.Future<void>);
} }
+8 -9
View File
@@ -9,13 +9,12 @@ void main() {
// startup, throwing PlatformException(channel-error, ...). // startup, throwing PlatformException(channel-error, ...).
// registerBackgroundSync() must absorb the failure and let the app continue. // registerBackgroundSync() must absorb the failure and let the app continue.
test( test(
'registerBackgroundSync completes without throwing when plugin is unavailable', 'registerBackgroundSync completes without throwing when plugin is unavailable',
() async { () async {
// In the unit-test environment the native WorkManager plugin is not // In the unit-test environment the native WorkManager plugin is not
// registered, so Workmanager().initialize() throws a PlatformException or // registered, so Workmanager().initialize() throws a PlatformException or
// MissingPluginException. The fix catches it. This test fails before the // MissingPluginException. The fix catches it. This test fails before the
// fix (exception propagates) and passes after it (exception is swallowed). // fix (exception propagates) and passes after it (exception is swallowed).
await expectLater(registerBackgroundSync(), completes); await expectLater(registerBackgroundSync(), completes);
}, });
);
} }
+2 -3
View File
@@ -86,9 +86,8 @@ void main() {
final result = injectInlineImages(html, msg); final result = injectInlineImages(html, msg);
// Extract base64 payload from the data URI. // Extract base64 payload from the data URI.
final match = RegExp( final match =
r'data:image/png;base64,([A-Za-z0-9+/=]+)', RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
).firstMatch(result);
expect(match, isNotNull); expect(match, isNotNull);
final decoded = base64.decode(match!.group(1)!); final decoded = base64.decode(match!.group(1)!);
expect(decoded.length, greaterThan(0)); expect(decoded.length, greaterThan(0));
+17 -4
View File
@@ -44,7 +44,10 @@ abstract class EmailRepositoryContract {
void run() { void run() {
test('observeEmails starts empty', () async { test('observeEmails starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty); expect(
await repo.observeEmails(_account.id, 'INBOX').first,
isEmpty,
);
}); });
test('observeEmails emits inserted email', () async { test('observeEmails emits inserted email', () async {
@@ -58,7 +61,10 @@ abstract class EmailRepositoryContract {
test('observeEmails only returns emails for the given mailbox', () async { test('observeEmails only returns emails for the given mailbox', () async {
final repo = await makeRepo(); final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX'); await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty); expect(
await repo.observeEmails(_account.id, 'Sent').first,
isEmpty,
);
}); });
test('observeEmails orders by receivedAt descending', () async { test('observeEmails orders by receivedAt descending', () async {
@@ -110,7 +116,11 @@ abstract class EmailRepositoryContract {
test('setFlag flagged updates isFlagged', () async { test('setFlag flagged updates isFlagged', () async {
final repo = await makeRepo(); final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX'); await insertEmail(
repo,
id: 'er-acc:11',
mailboxPath: 'INBOX',
);
await repo.setFlag('er-acc:11', flagged: true); await repo.setFlag('er-acc:11', flagged: true);
final email = await repo.getEmail('er-acc:11'); final email = await repo.getEmail('er-acc:11');
expect(email!.isFlagged, isTrue); expect(email!.isFlagged, isTrue);
@@ -147,7 +157,10 @@ abstract class EmailRepositoryContract {
test('observeThreads starts empty', () async { test('observeThreads starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty); expect(
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
}); });
} }
} }
+199 -206
View File
@@ -453,49 +453,47 @@ void main() {
expect(results.first.subject, 'foobar baz'); expect(results.first.subject, 'foobar baz');
}); });
test( test('searchAddresses returns results sorted by most recently used',
'searchAddresses returns results sorted by most recently used', () async {
() async { final r = _makeRepos();
final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw');
await r.accounts.addAccount(_account, 'pw');
final older = DateTime(2024); final older = DateTime(2024);
final newer = DateTime(2024, 6); final newer = DateTime(2024, 6);
// Two emails — older one has alice@, newer one has bob@. // Two emails — older one has alice@, newer one has bob@.
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:old', id: 'acc-1:old',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 1, uid: 1,
receivedAt: older, receivedAt: older,
toAddresses: const Value( toAddresses: const Value(
'[{"name":"Alice","email":"alice@example.com"}]', '[{"name":"Alice","email":"alice@example.com"}]',
),
), ),
); ),
await r.db.into(r.db.emails).insert( );
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:new', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:new',
mailboxPath: 'Sent', accountId: 'acc-1',
uid: 2, mailboxPath: 'Sent',
receivedAt: newer, uid: 2,
toAddresses: const Value( receivedAt: newer,
'[{"name":"Bob","email":"bob@example.com"}]', toAddresses: const Value(
), '[{"name":"Bob","email":"bob@example.com"}]',
), ),
); ),
);
// Query matching both; newer (bob) should come first. // Query matching both; newer (bob) should come first.
final results = await r.emails.searchAddresses(null, 'example'); final results = await r.emails.searchAddresses(null, 'example');
expect(results.map((a) => a.email).toList(), [ expect(
'bob@example.com', results.map((a) => a.email).toList(),
'alice@example.com', ['bob@example.com', 'alice@example.com'],
]); );
}, });
);
// ── IMAP method tests ──────────────────────────────────────────────────── // ── IMAP method tests ────────────────────────────────────────────────────
@@ -699,47 +697,47 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test( test('snooze flush selects src mailbox and moves email to Snoozed',
'snooze flush selects src mailbox and moves email to Snoozed', () async {
() async { final spy = SnoozeSpyImapClient();
final spy = SnoozeSpyImapClient(); final r = _makeRepos(
final r = _makeRepos(imapConnect: (_, __, ___) async => spy); imapConnect: (_, __, ___) async => spy,
await r.accounts.addAccount(_account, 'pw'); );
await r.db.into(r.db.emails).insert( await r.accounts.addAccount(_account, 'pw');
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:5', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:5',
mailboxPath: 'Snoozed', accountId: 'acc-1',
uid: 5, mailboxPath: 'Snoozed',
receivedAt: DateTime(2024), uid: 5,
), receivedAt: DateTime(2024),
); ),
await r.db.into(r.db.pendingChanges).insert( );
PendingChangesCompanion.insert( await r.db.into(r.db.pendingChanges).insert(
accountId: 'acc-1', PendingChangesCompanion.insert(
resourceType: 'Email', accountId: 'acc-1',
resourceId: 'acc-1:5', resourceType: 'Email',
changeType: 'snooze', resourceId: 'acc-1:5',
payload: jsonEncode({ changeType: 'snooze',
'uid': 5, payload: jsonEncode({
'src': 'INBOX', 'uid': 5,
'dest': 'Snoozed', 'src': 'INBOX',
'until': '2026-05-10T15:00:00.000', 'dest': 'Snoozed',
}), 'until': '2026-05-10T15:00:00.000',
createdAt: DateTime.now(), }),
), createdAt: DateTime.now(),
); ),
);
await r.emails.flushPendingChanges('acc-1', 'pw'); await r.emails.flushPendingChanges('acc-1', 'pw');
// Change successfully applied — removed from queue. // Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Source mailbox extracted from 'src', not 'mailboxPath'. // Source mailbox extracted from 'src', not 'mailboxPath'.
expect(spy.selectedMailbox, 'INBOX'); expect(spy.selectedMailbox, 'INBOX');
expect(spy.createdMailbox, 'Snoozed'); expect(spy.createdMailbox, 'Snoozed');
expect(spy.movedToMailbox, 'Snoozed'); expect(spy.movedToMailbox, 'Snoozed');
}, });
);
}); });
group('Snooze', () { group('Snooze', () {
@@ -1642,123 +1640,119 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test( test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', () async {
() async { final List<Map<String, dynamic>> capturedBodies = [];
final List<Map<String, dynamic>> capturedBodies = []; final client = MockClient((req) async {
final client = MockClient((req) async { if (req.url.path.contains('well-known')) {
if (req.url.path.contains('well-known')) { return http.Response(
return http.Response( jsonEncode({
jsonEncode({ 'apiUrl': 'https://jmap.example.com/api/',
'apiUrl': 'https://jmap.example.com/api/', 'accounts': {
'accounts': { 'acct1': {'name': 'alice@example.com', 'isPersonal': true},
'acct1': {'name': 'alice@example.com', 'isPersonal': true}, },
}, 'primaryAccounts': {
'primaryAccounts': { 'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:core': 'acct1', 'urn:ietf:params:jmap:mail': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1', },
}, 'capabilities': {},
'capabilities': {}, 'username': 'alice@example.com',
'username': 'alice@example.com', 'state': 'sess1',
'state': 'sess1', }),
}), 200,
200, );
); }
} final body = jsonDecode(req.body) as Map<String, dynamic>;
final body = jsonDecode(req.body) as Map<String, dynamic>; capturedBodies.add(body);
capturedBodies.add(body); final calls = body['methodCalls'] as List;
final calls = body['methodCalls'] as List; final methodName = (calls.first as List)[0] as String;
final methodName = (calls.first as List)[0] as String; if (methodName == 'Mailbox/set') {
if (methodName == 'Mailbox/set') {
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0',
],
],
}),
200,
);
}
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
'sessionState': 's1', 'sessionState': 's1',
'methodResponses': [ 'methodResponses': [
[ [
'Email/set', 'Mailbox/set',
{'accountId': 'acct1', 'updated': {}}, {
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0', '0',
], ],
], ],
}), }),
200, 200,
); );
}); }
return http.Response(
final r = _makeRepos(httpClient: client); jsonEncode({
await seedChange( 'sessionState': 's1',
r.db, 'methodResponses': [
r.accounts, [
changeType: 'snooze', 'Email/set',
payload: jsonEncode({ {'accountId': 'acct1', 'updated': {}},
'uid': 0, '0',
'src': 'mbx-inbox', ],
'dest': 'Snoozed', ],
'until': '2026-05-10T15:00:00.000',
}), }),
200,
); );
});
await r.emails.flushPendingChanges('jmap-1', 'pw'); final r = _makeRepos(httpClient: client);
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
// Change successfully applied — removed from queue. await r.emails.flushPendingChanges('jmap-1', 'pw');
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// First API call should be Mailbox/set to create the Snoozed folder. // Change successfully applied — removed from queue.
expect(capturedBodies, hasLength(2)); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
final firstCall =
((capturedBodies.first['methodCalls'] as List).first as List)[0];
expect(firstCall, 'Mailbox/set');
// Second call should be Email/set using the newly created mailbox ID. // First API call should be Mailbox/set to create the Snoozed folder.
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first expect(capturedBodies, hasLength(2));
as List)[1] as Map<String, dynamic>; final firstCall =
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1'] ((capturedBodies.first['methodCalls'] as List).first as List)[0];
as Map<String, dynamic>; expect(firstCall, 'Mailbox/set');
expect(update['mailboxIds/mbx-snoozed'], true);
},
);
test( // Second call should be Email/set using the newly created mailbox ID.
'snooze uses existing mailbox ID when dest is already a JMAP ID', final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
() async { as List)[1] as Map<String, dynamic>;
final r = _makeRepos(httpClient: mockFlush(200)); final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
await seedChange( as Map<String, dynamic>;
r.db, expect(update['mailboxIds/mbx-snoozed'], true);
r.accounts, });
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'mbx-snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
await r.emails.flushPendingChanges('jmap-1', 'pw'); test('snooze uses existing mailbox ID when dest is already a JMAP ID',
() async {
final r = _makeRepos(httpClient: mockFlush(200));
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'mbx-snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
// Change applied without needing Mailbox/set (dest was already a valid ID). await r.emails.flushPendingChanges('jmap-1', 'pw');
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}, // Change applied without needing Mailbox/set (dest was already a valid ID).
); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
});
}); });
group('JMAP syncEmails body caching', () { group('JMAP syncEmails body caching', () {
@@ -2288,42 +2282,41 @@ void main() {
group('concurrent moves', () { group('concurrent moves', () {
test( test(
'two simultaneous moves enqueue two changes and leave email in last destination', 'two simultaneous moves enqueue two changes and leave email in last destination',
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 5, uid: 5,
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
// Fire both moves without awaiting to exercise concurrent enqueue logic. // Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive'); final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash'); final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]); await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2)); expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move')); expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations = final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet(); changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash'])); expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
expect( expect(
email!.mailboxPath, email!.mailboxPath,
anyOf('Archive', 'Trash'), anyOf('Archive', 'Trash'),
reason: reason:
'email must be optimistically moved to one of the two destinations', 'email must be optimistically moved to one of the two destinations',
); );
}, });
);
}); });
group('IMAP SMTP auth failure', () { group('IMAP SMTP auth failure', () {
@@ -61,7 +61,10 @@ abstract class MailboxRepositoryContract {
test('findMailboxByRole returns null when no match', () async { test('findMailboxByRole returns null when no match', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull); expect(
await repo.findMailboxByRole(_account.id, 'archive'),
isNull,
);
}); });
test('findMailboxByRole returns the matching mailbox', () async { test('findMailboxByRole returns the matching mailbox', () async {
-175
View File
@@ -14,7 +14,6 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage; import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart'; import 'db_test_helper.dart';
import 'fake_imap.dart' show SnoozeSpyImapClient;
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account( const _account = Account(
@@ -433,179 +432,5 @@ void main() {
expect(result, isNotNull); expect(result, isNotNull);
expect(result!.role, 'inbox'); expect(result!.role, 'inbox');
}); });
group('createMailboxWithRole', () {
test('IMAP: creates mailbox on server and persists with role', () async {
final spy = SnoozeSpyImapClient();
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => spy,
);
await accounts.addAccount(_account, 'pw');
final result = await mailboxes.createMailboxWithRole(
'acc-1',
'Archive',
'archive',
);
expect(spy.createdMailbox, 'Archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'Archive');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test('JMAP: creates mailbox on server and persists with role', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-mailbox': {'id': 'mbx-archive'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes.createMailboxWithRole(
'jmap-1',
'Archive',
'archive',
);
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'mbx-archive');
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test('JMAP: throws when server returns no created ID', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': null,
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()),
);
});
});
group('syncMailboxes IMAP preserves manually-set role', () {
test(
'existing role is kept when server returns no special-use flag',
() async {
final spy = SnoozeSpyImapClient();
// Make listMailboxes return a plain folder without \Archive.
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => fakeClient,
);
await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
role: const Value('archive'),
),
);
await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(
found,
isNotNull,
reason: 'Manually-set role should be preserved after sync',
);
expect(found!.path, 'Archive');
// Suppress unused warning on spy.
expect(spy, isNotNull);
},
);
});
}); });
} }
/// Fake IMAP client that lists one mailbox named 'Archive' without any
/// special-use flags, and logs out cleanly.
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
@override
Future<List<imap.Mailbox>> listMailboxes({
String path = '""',
bool recursive = false,
List<String>? mailboxPatterns,
List<String>? selectionOptions,
List<imap.ReturnOption>? returnOptions,
}) async =>
[
imap.Mailbox(
encodedName: 'Archive',
encodedPath: 'Archive',
pathSeparator: '/',
flags: [], // No \Archive special-use flag
),
];
@override
Future<imap.Mailbox> statusMailbox(
imap.Mailbox mailbox,
List<imap.StatusFlags> flags,
) async =>
mailbox;
@override
Future<dynamic> logout() async {}
}
+69 -116
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 36); expect(db.schemaVersion, 32);
await db.close(); await db.close();
}); });
@@ -178,50 +178,35 @@ void main() {
// v28: mime_tree_json column on email_bodies. // v28: mime_tree_json column on email_bodies.
await db await db
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0') .customSelect(
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.get(); .get();
// v29: local_sieve_scripts table. // v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = await _tableColumns( final syncLogMailboxColumns =
db, await _tableColumns(db, 'sync_log_mailboxes');
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get(); await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test( test(
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id', 'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async { () async {
final dbFile = File('test_migration_v22.db'); final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL. // Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path); final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute(''' rawDb.execute('''
CREATE TABLE accounts ( CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
@@ -242,7 +227,7 @@ void main() {
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1)) verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE drafts ( CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL, account_id TEXT NULL,
@@ -254,7 +239,7 @@ void main() {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE mailboxes ( CREATE TABLE mailboxes (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -265,7 +250,7 @@ void main() {
role TEXT NULL role TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE emails ( CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -289,7 +274,7 @@ void main() {
snoozed_from_mailbox_path TEXT NULL snoozed_from_mailbox_path TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE threads ( CREATE TABLE threads (
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL, mailbox_path TEXT NOT NULL,
@@ -306,7 +291,7 @@ void main() {
PRIMARY KEY (account_id, mailbox_path, id) PRIMARY KEY (account_id, mailbox_path, id)
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE email_bodies ( CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE, email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
text_body TEXT NULL, text_body TEXT NULL,
@@ -316,7 +301,7 @@ void main() {
headers_json TEXT NULL headers_json TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE sync_logs ( CREATE TABLE sync_logs (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -333,7 +318,7 @@ void main() {
protocol_log TEXT NULL protocol_log TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE sync_log_mailboxes ( CREATE TABLE sync_log_mailboxes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE, sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
@@ -343,81 +328,64 @@ void main() {
bytes_transferred INTEGER NOT NULL DEFAULT 0 bytes_transferred INTEGER NOT NULL DEFAULT 0
); );
'''); ''');
rawDb.execute('PRAGMA user_version = 22;'); rawDb.execute('PRAGMA user_version = 22;');
rawDb.close(); rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile)); final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration. // Trigger migration.
await db.select(db.accounts).get(); await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails'); final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header')); expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts'); final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id')); expect(draftColumns, contains('imap_server_id'));
// v25: new indexes on mailboxes and threads. // v25: new indexes on mailboxes and threads.
final allIndexes = await db final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'") .customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get(); .get();
final indexNames = final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
allIndexes.map((r) => r.read<String>('name')).toSet(); expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('mailboxes_account_id')); expect(indexNames, contains('threads_latest_date'));
expect(indexNames, contains('threads_latest_date'));
// v26: FTS5 virtual table and triggers. // v26: FTS5 virtual table and triggers.
final allTriggers = await db final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get(); .get();
final triggerNames = final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet(); allTriggers.map((r) => r.read<String>('name')).toSet();
expect( expect(
triggerNames, triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
); );
await db.customSelect('SELECT count(*) FROM email_fts').get(); await db.customSelect('SELECT count(*) FROM email_fts').get();
// v27: search_history_entries table. // v27: search_history_entries table.
await db await db
.customSelect('SELECT count(*) FROM search_history_entries') .customSelect('SELECT count(*) FROM search_history_entries')
.get(); .get();
// v28: mime_tree_json column on email_bodies. // v28: mime_tree_json column on email_bodies.
await db await db
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0') .customSelect(
.get(); 'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.get();
// v29: local_sieve_scripts table. // v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = await _tableColumns( final syncLogMailboxColumns =
db, await _tableColumns(db, 'sync_log_mailboxes');
'sync_log_mailboxes', expect(syncLogMailboxColumns, contains('duration_ms'));
);
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs. await db.close();
final syncLogColumns = await _tableColumns(db, 'sync_logs'); if (dbFile.existsSync()) dbFile.deleteSync();
expect(syncLogColumns, contains('error_stack_trace')); });
expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table. test('fresh install creates all tables at schemaVersion 32', () async {
await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -444,7 +412,6 @@ void main() {
'local_sieve_scripts', // v29 'local_sieve_scripts', // v29
'share_keys', // v31 'share_keys', // v31
'local_sieve_applied', // v32 'local_sieve_applied', // v32
'user_preferences', // v34
]), ]),
); );
@@ -455,24 +422,10 @@ void main() {
expect(draftColumns, contains('imap_server_id')); expect(draftColumns, contains('imap_server_id'));
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = await _tableColumns( final syncLogMailboxColumns =
db, await _tableColumns(db, 'sync_log_mailboxes');
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
}); });
}); });
+8 -9
View File
@@ -9,15 +9,14 @@ void main() {
// absent at startup, throwing MissingPluginException (or a similar error). // absent at startup, throwing MissingPluginException (or a similar error).
// initNotifications() must absorb the failure and let the app continue. // initNotifications() must absorb the failure and let the app continue.
test( test(
'initNotifications completes without throwing when plugin is unavailable', 'initNotifications completes without throwing when plugin is unavailable',
() async { () async {
// In the unit-test environment the native plugin is not registered, so // In the unit-test environment the native plugin is not registered, so
// _plugin.initialize() throws. The fix catches it and keeps _initialized // _plugin.initialize() throws. The fix catches it and keeps _initialized
// false. This test fails before the fix (exception propagates) and passes // false. This test fails before the fix (exception propagates) and passes
// after it (exception is swallowed). // after it (exception is swallowed).
await expectLater(initNotifications(), completes); await expectLater(initNotifications(), completes);
}, });
);
test('showNewMailNotification completes without throwing', () async { test('showNewMailNotification completes without throwing', () async {
// Platform.isAndroid is false in tests, so this returns early without // Platform.isAndroid is false in tests, so this returns early without
@@ -62,21 +62,6 @@ class _FakeMailboxes implements MailboxRepository {
null; null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
+10 -21
View File
@@ -26,9 +26,11 @@ class _FakeAccounts implements AccountRepository {
@override @override
Stream<List<Account>> observeAccounts() => Stream.value(accounts); Stream<List<Account>> observeAccounts() => Stream.value(accounts);
@override @override
Future<Account?> getAccount(String id) async => accounts Future<Account?> getAccount(String id) async =>
.cast<Account?>() accounts.cast<Account?>().firstWhere(
.firstWhere((a) => a?.id == id, orElse: () => null); (a) => a?.id == id,
orElse: () => null,
);
@override @override
Future<void> addAccount(Account account, String password) async {} Future<void> addAccount(Account account, String password) async {}
@override @override
@@ -52,21 +54,6 @@ class _FakeMailboxes implements MailboxRepository {
null; null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _CountingEmails implements EmailRepository { class _CountingEmails implements EmailRepository {
@@ -92,7 +79,11 @@ class _CountingEmails implements EmailRepository {
@override @override
Future<int> flushPendingChanges(String accountId, String password) async => 0; Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override @override
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) => Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
@@ -179,8 +170,6 @@ class _FakeSyncLog implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+3 -1
View File
@@ -47,7 +47,9 @@ void main() {
test('parsePublicKeyQr returns null for invalid input', () { test('parsePublicKeyQr returns null for invalid input', () {
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull); expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
expect( expect(
ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'), ShareEncryptionService.parsePublicKeyQr(
'sharedinbox.de:pubkey:v1:!!!',
),
isNull, isNull,
); );
expect( expect(
+7 -5
View File
@@ -73,7 +73,11 @@ void main() {
SieveRule( SieveRule(
joinType: 'single', joinType: 'single',
conditions: [ conditions: [
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']), HeaderCondition(
['from', 'reply-to'],
':is',
['boss@work.com'],
),
], ],
actions: [ actions: [
FlagAction([r'\Important']), FlagAction([r'\Important']),
@@ -117,10 +121,8 @@ void main() {
), ),
]; ];
final ctx = interp.execute( final ctx =
rules, interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
_email(subject: 'Weekly Newsletter Issue'),
);
expect(ctx.targetFolders, contains('Bulk')); expect(ctx.targetFolders, contains('Bulk'));
}); });
}); });
+2 -3
View File
@@ -261,9 +261,8 @@ if exists "X-Spam-Flag" {
group('SieveParser — rule model', () { group('SieveParser — rule model', () {
test('simple if produces one rule with branchGroupId', () { test('simple if produces one rule with branchGroupId', () {
final rules = parser.parse( final rules =
'if header :contains "Subject" "x" { discard; }', parser.parse('if header :contains "Subject" "x" { discard; }');
);
expect(rules, hasLength(1)); expect(rules, hasLength(1));
expect(rules.first.branchGroupId, isNotNull); expect(rules.first.branchGroupId, isNotNull);
expect(rules.first.conditions, hasLength(1)); expect(rules.first.conditions, hasLength(1));
@@ -126,36 +126,4 @@ void main() {
expect(rows.first.result, 'error'); expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
}); });
test(
'stores and retrieves stackTrace and isPermanent on error entries',
() async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 3, 1, 9);
final end = DateTime(2024, 3, 1, 9, 0, 1);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
await repo.log(
accountId: 'acc1',
success: false,
errorMessage: 'MissingPluginException',
stackTrace: fakeTrace,
isPermanent: true,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
mailboxesSynced: 0,
pendingFlushed: 0,
bytesTransferred: 0,
startedAt: start,
finishedAt: end,
);
final entries = await repo.observeSyncLogs('acc1').first;
final entry = entries.firstWhere((e) => e.startedAt == start);
expect(entry.stackTrace, fakeTrace);
expect(entry.isPermanent, true);
expect(entry.errorMessage, 'MissingPluginException');
},
);
} }
+4 -6
View File
@@ -260,9 +260,8 @@ void main() {
expect(original!.messageId, isNull); // set a messageId so lookup works expect(original!.messageId, isNull); // set a messageId so lookup works
// Seed a messageId so undo can find the email after UID change. // Seed a messageId so undo can find the email after UID change.
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write( await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
const EmailsCompanion(messageId: Value('msg-101@test')), .write(const EmailsCompanion(messageId: Value('msg-101@test')));
);
final originalWithMsgId = await repo.getEmail(oldEmailId); final originalWithMsgId = await repo.getEmail(oldEmailId);
@@ -304,9 +303,8 @@ void main() {
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 4. Verify the current email row is now in INBOX. // 4. Verify the current email row is now in INBOX.
final inInbox = await (db.select( final inInbox = await (db.select(db.emails)
db.emails, ..where((t) => t.mailboxPath.equals('INBOX')))
)..where((t) => t.mailboxPath.equals('INBOX')))
.get(); .get();
expect( expect(
inInbox, inInbox,
+64 -64
View File
@@ -122,74 +122,70 @@ void main() {
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
}); });
test( test('undo pushes inverse action into log when destinationMailboxPath is set',
'undo pushes inverse action into log when destinationMailboxPath is set', () async {
() async { final action = UndoAction(
final action = UndoAction( id: 'del1',
id: 'del1', accountId: 'acc1',
accountId: 'acc1', type: UndoType.delete,
type: UndoType.delete, emailIds: ['e1'],
emailIds: ['e1'], sourceMailboxPath: 'INBOX',
sourceMailboxPath: 'INBOX', destinationMailboxPath: 'Trash',
destinationMailboxPath: 'Trash', );
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when( when(
mockEmailRepo.cancelPendingChange(any, any), mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
await notifier.pushAction(action); await notifier.pushAction(action);
await notifier.undo(actionId: 'del1'); await notifier.undo(actionId: 'del1');
// Original entry stays; inverse is added. // Original entry stays; inverse is added.
final log = container.read(undoServiceProvider); final log = container.read(undoServiceProvider);
expect(log.length, 2); expect(log.length, 2);
expect(log[0].id, 'del1'); expect(log[0].id, 'del1');
final inv = log[1]; final inv = log[1];
expect(inv.id, 'del1-inv'); expect(inv.id, 'del1-inv');
expect(inv.type, UndoType.move); expect(inv.type, UndoType.move);
expect(inv.emailIds, ['e1']); expect(inv.emailIds, ['e1']);
expect(inv.sourceMailboxPath, 'Trash'); expect(inv.sourceMailboxPath, 'Trash');
expect(inv.destinationMailboxPath, 'INBOX'); expect(inv.destinationMailboxPath, 'INBOX');
verify( verify(
mockUndoRepo.saveAction( mockUndoRepo.saveAction(
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')), argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
), ),
).called(1); ).called(1);
}, });
);
test( test('undo without destinationMailboxPath does not push inverse action',
'undo without destinationMailboxPath does not push inverse action', () async {
() async { final action = UndoAction(
final action = UndoAction( id: 'mv1',
id: 'mv1', accountId: 'acc1',
accountId: 'acc1', type: UndoType.move,
type: UndoType.move, emailIds: ['e1'],
emailIds: ['e1'], sourceMailboxPath: 'INBOX',
sourceMailboxPath: 'INBOX', // no destinationMailboxPath
// no destinationMailboxPath );
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when( when(
mockEmailRepo.cancelPendingChange(any, any), mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
await notifier.pushAction(action); await notifier.pushAction(action);
await notifier.undo(actionId: 'mv1'); await notifier.undo(actionId: 'mv1');
// Original entry stays; no inverse since no destinationMailboxPath. // Original entry stays; no inverse since no destinationMailboxPath.
final log = container.read(undoServiceProvider); final log = container.read(undoServiceProvider);
expect(log.length, 1); expect(log.length, 1);
expect(log.first.id, 'mv1'); expect(log.first.id, 'mv1');
}, });
);
test('undo with actionId removes and undos specific action', () async { test('undo with actionId removes and undos specific action', () async {
// action1 has no destination → no inverse action // action1 has no destination → no inverse action
@@ -354,9 +350,13 @@ void main() {
); );
// Simulate slow DB load // Simulate slow DB load
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer( when(
(_) => mockUndoRepo.getHistory(limit: anyNamed('limit')),
Future.delayed(const Duration(milliseconds: 10), () => [persisted]), ).thenAnswer(
(_) => Future.delayed(
const Duration(milliseconds: 10),
() => [persisted],
),
); );
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
+8 -50
View File
@@ -27,28 +27,11 @@ class MockUrlLauncher extends Mock
} }
} }
class ThrowingUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
@override
Future<bool> canLaunch(String? url) async => true;
@override
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: '
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
);
}
}
Widget _buildScreen({List<Account> accounts = const []}) { Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider
FakeAccountRepository(accounts), .overrideWithValue(FakeAccountRepository(accounts)),
),
], ],
child: const MaterialApp(home: AboutScreen()), child: const MaterialApp(home: AboutScreen()),
); );
@@ -81,9 +64,6 @@ void main() {
expect(find.textContaining('Dark Mode'), findsWidgets); expect(find.textContaining('Dark Mode'), findsWidgets);
expect(find.textContaining('IMAP Accounts'), findsWidgets); expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets); expect(find.textContaining('JMAP Accounts'), findsWidgets);
expect(find.textContaining('Locale'), findsWidgets);
expect(find.textContaining('Text Scale'), findsWidgets);
expect(find.textContaining('DB Schema Version'), findsWidgets);
// Buttons are in the body, not in the AppBar actions // Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget); expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget); expect(find.byIcon(Icons.bug_report), findsOneWidget);
@@ -152,10 +132,8 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( () => tester.binding.defaultBinaryMessenger
SystemChannels.platform, .setMockMethodCallHandler(SystemChannels.platform, null),
null,
),
); );
await tester.pumpWidget(_buildScreen()); await tester.pumpWidget(_buildScreen());
@@ -173,10 +151,10 @@ void main() {
expect(clipboardText, contains('Dark Mode')); expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts')); expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts')); expect(clipboardText, contains('JMAP Accounts'));
expect(clipboardText, contains('Locale')); expect(
expect(clipboardText, contains('Text Scale')); clipboardText,
expect(clipboardText, contains('DB Schema Version')); contains('[sharedinbox.de](https://sharedinbox.de)'),
expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)')); );
}); });
testWidgets('AboutScreen create-issue button opens Codeberg URL', ( testWidgets('AboutScreen create-issue button opens Codeberg URL', (
@@ -202,24 +180,4 @@ void main() {
); );
expect(mock.launchedUrl, contains('1.2.3%2B99')); expect(mock.launchedUrl, contains('1.2.3%2B99'));
}); });
testWidgets(
'AboutScreen link tap with failed url_launcher shows error snackbar',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.textContaining('sharedinbox.de').first);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
},
);
} }
+2 -94
View File
@@ -23,7 +23,7 @@ void main() {
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget); expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
}); });
testWidgets('shows expiry countdown hint', (tester) async { testWidgets('shows 20-minute expiry hint', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/receive', initialLocation: '/accounts/receive',
@@ -32,100 +32,8 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('expires in'), findsOneWidget); expect(find.textContaining('20 minutes'), findsOneWidget);
}); });
testWidgets(
'step 2 button shows text-input fallback on platforms without camera',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
// On Linux (desktop, no camera) the text fallback field must appear.
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
},
);
testWidgets(
'step 2 — valid encrypted QR imports account via text fallback',
(tester) async {
// Pre-generate a key pair so we can encrypt a QR code with the same
// material the screen will use for decryption.
final material = await ShareEncryptionService.generateKeyPair();
final repo = FakeShareKeyRepository(material: material);
const account = Account(
id: 'src-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
final encryptedQr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: material.keyId,
recipientPublicKeyBytes: material.publicKeyBytes,
accounts: [
AccountPayload(accountJson: account.toJson(), password: 'secret'),
],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(shareKeyRepository: repo),
),
);
await tester.pumpAndSettle(); // key generation completes
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('encryptedCodeField')),
encryptedQr,
);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(find.text('Imported 1 account successfully.'), findsOneWidget);
},
);
testWidgets(
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('encryptedCodeField')),
'not-a-valid-qr-code',
);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
// Screen returns to the pub-key step with an error message visible.
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
expect(find.textContaining('Import failed:'), findsWidgets);
},
);
}); });
group('AccountSendScreen', () { group('AccountSendScreen', () {
-69
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'helpers.dart'; import 'helpers.dart';
@@ -207,73 +206,5 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(find.text('sharedinbox.de'), findsOneWidget); expect(find.text('sharedinbox.de'), findsOneWidget);
}); });
testWidgets('shows Healthy when sync health is healthy', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Healthy'), findsOneWidget);
});
testWidgets('shows discrepancy details when sync health has discrepancies',
(
tester,
) async {
const summary =
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: false,
discrepancySummary: summary,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('missing locally: 3'), findsOneWidget);
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
});
testWidgets('sync health row is positioned below the account name row', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos));
});
}); });
} }
-54
View File
@@ -1,54 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends CachingAssetBundle {
final Map<String, String> _assets;
_FakeAssetBundle(this._assets);
@override
Future<ByteData> load(String key) async {
if (_assets.containsKey(key)) {
final encoded = utf8.encode(_assets[key]!);
return ByteData.view(Uint8List.fromList(encoded).buffer);
}
throw FlutterError('Asset not found: "$key"');
}
}
const _fakeChangelog =
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
void main() {
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('ChangeLog'), findsOneWidget);
expect(find.textContaining('initial release'), findsOneWidget);
expect(find.textContaining('resolve crash'), findsOneWidget);
expect(find.textContaining('Error loading changelog'), findsNothing);
});
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
}
+41 -75
View File
@@ -96,10 +96,8 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( () => tester.binding.defaultBinaryMessenger
SystemChannels.platform, .setMockMethodCallHandler(SystemChannels.platform, null),
null,
),
); );
const exception = 'TestException: clipboard test'; const exception = 'TestException: clipboard test';
@@ -118,87 +116,57 @@ void main() {
expect(clipboardText, isNotNull); expect(clipboardText, isNotNull);
expect(clipboardText, contains('App Version: 1.0.0+42')); expect(clipboardText, contains('App Version: 1.0.0+42'));
expect(clipboardText, contains('Build Mode:'));
expect(clipboardText, contains('Platform:')); expect(clipboardText, contains('Platform:'));
expect(clipboardText, contains('Dart:'));
expect(clipboardText, contains('Timestamp:'));
expect(clipboardText, contains('TestException: clipboard test')); expect(clipboardText, contains('TestException: clipboard test'));
// GIT_HASH is empty in test builds — no Git Commit line expected // GIT_HASH is empty in test builds — no Git Commit line expected
expect(clipboardText, isNot(contains('Git Commit:'))); expect(clipboardText, isNot(contains('Git Commit:')));
}, },
); );
testWidgets('CrashScreen shows git hash as clickable link above stacktrace', ( testWidgets(
tester, 'CrashScreen shows git hash as clickable link above stacktrace',
) async { (tester) async {
tester.view.physicalSize = const Size(800, 1200); tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize()); addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher(); final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock; UrlLauncherPlatform.instance = mock;
const exception = 'TestException: git hash test'; const exception = 'TestException: git hash test';
final stackTrace = StackTrace.current; final stackTrace = StackTrace.current;
const testHash = 'abc1234'; const testHash = 'abc1234';
await tester.pumpWidget( await tester.pumpWidget(
CrashScreen( CrashScreen(
exception: exception, exception: exception,
stackTrace: stackTrace, stackTrace: stackTrace,
gitHash: testHash, gitHash: testHash,
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Git hash link should be present // Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234'); final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(gitLinkFinder, findsOneWidget); expect(gitLinkFinder, findsOneWidget);
// Link must appear above the stack trace // Link must appear above the stack trace
final stackTraceFinder = find.text('Stack Trace:'); final stackTraceFinder = find.text('Stack Trace:');
expect( expect(
tester.getTopLeft(gitLinkFinder).dy, tester.getTopLeft(gitLinkFinder).dy,
lessThan(tester.getTopLeft(stackTraceFinder).dy), lessThan(tester.getTopLeft(stackTraceFinder).dy),
); );
// Tapping the link should open the Codeberg commit URL // Tapping the link should open the Codeberg commit URL
await tester.tap(gitLinkFinder); await tester.tap(gitLinkFinder);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
mock.launchedUrl, mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'), equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
); );
}); },
);
testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
const exception = 'TestException: info row test';
final stackTrace = StackTrace.current;
await tester.pumpWidget(
MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace),
),
);
await tester.pumpAndSettle();
// Info row shows app version (from mock), build mode, and platform OS.
expect(find.textContaining('1.0.0+42'), findsWidgets);
// In test builds kDebugMode is true.
expect(find.textContaining('debug'), findsOneWidget);
// Platform OS is always present (linux in CI, android/ios on device).
expect(
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
findsWidgets,
);
});
testWidgets( testWidgets(
'CrashScreen shows app version as clickable link when git hash is set', 'CrashScreen shows app version as clickable link when git hash is set',
@@ -264,10 +232,8 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( () => tester.binding.defaultBinaryMessenger
SystemChannels.platform, .setMockMethodCallHandler(SystemChannels.platform, null),
null,
),
); );
const exception = 'TestException: version link clipboard test'; const exception = 'TestException: version link clipboard test';
+6 -62
View File
@@ -106,64 +106,7 @@ void main() {
}); });
testWidgets( testWidgets(
'try connection button is disabled when no password stored or entered', 'try connection shows password required when no password stored', (
(tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')),
);
expect(button.onPressed, isNull);
},
);
testWidgets(
'try connection button is enabled after typing password with no stored password',
(tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('editPasswordField')),
'mypassword',
);
await tester.pump();
final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')),
);
expect(button.onPressed, isNotNull);
},
);
testWidgets('save button is disabled when no password stored or entered', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
@@ -182,10 +125,11 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final button = tester.widget<FilledButton>( await tester.tap(find.byKey(const Key('editTryConnectionButton')));
find.widgetWithText(FilledButton, 'Save'), await tester.pumpAndSettle();
);
expect(button.onPressed, isNull); // App must not crash; password field shows a validation error.
expect(find.text('Required'), findsOneWidget);
}); });
testWidgets('connection error shows error message', (tester) async { testWidgets('connection error shows error message', (tester) async {

Some files were not shown because too many files have changed in this diff Show More