Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 badc449866 feat: configurable next action after single mail view (#300)
Add user preference to control what happens after performing an action
(delete, archive, move, snooze, mark as unread) in the single mail view.
The default is to show the next message in the mailbox; users can opt
to return to the mailbox instead.

- New AfterMailViewAction enum (nextMessage, showMailbox)
- DB schema v36: after_mail_view_action column on user_preferences
- UserPreferences model, repository interface and impl updated
- EmailDetailScreen: reads preference, finds next email before action,
  navigates via context.go() or context.pop() accordingly
- UserPreferencesScreen: new "After mail action" section with radio group
- Tests updated: FakeUserPreferencesRepository, migration test, new
  UserPreferencesScreen tests with scroll-to-reveal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:32:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a56847fdb5 feat: configurable back button position for single mail view (#299)
Add a mailViewButtonPosition preference (bottom/top) that controls
where the back button appears in the thread detail screen. Default
is bottom, showing a BottomAppBar with an arrow_back button and
hiding the AppBar's leading back button. Users can switch to top
to restore the standard AppBar back button. Preference is stored
in the UserPreferences DB table (schema v35) and configurable via
the Preferences screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:32:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ef3d278c5f fix: wrap bottom bar menu button in Builder to get Scaffold context
Scaffold.of() requires a descendant context of the Scaffold widget.
Using the State's build context (which is the Scaffold's parent) caused
an assertion failure when tapping the 'Open folders' button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:32:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e1ccfabfdd feat: configurable menu bar position for mailbox view (#298)
Move the folder navigation drawer trigger to the bottom by default,
matching the issue request. Add a user preferences DB table (schema v34)
and a settings screen so users can switch back to the top hamburger menu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:32:41 +02:00
124 changed files with 4349 additions and 4821 deletions
+2 -6
View File
@@ -4,18 +4,14 @@
# In systemd service:
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
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 \
jq \
stunnel4 \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# SOPS
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
&& chmod +x /usr/local/bin/sops
# Dagger CLI — pinned to match the engine version on the runner host
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
+148 -3
View File
@@ -1,14 +1,159 @@
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:
check:
name: Full Project Check
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- 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:
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
- name: Locate Docker daemon for local Dagger engine
run: |
# Skip if remote Dagger engine is already configured (preferred path)
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
echo "Remote Dagger engine configured, no local Docker needed."
exit 0
fi
# Try host Docker socket (DooD) if runner mounts it
if [ -S /var/run/docker.sock ]; then
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
echo "Docker available via host socket."
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
echo "CI will likely fail at the Dagger step." >&2
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger
- name: Prune Dagger cache after check
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+104 -81
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 2
- name: Detect Android and Linux changes
id: diff
@@ -34,78 +34,40 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent workflow run where deploy-playstore actually succeeded
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
# every run and the fallback diff to only cover HEAD~1..HEAD.
# Skip if this exact commit was already successfully deployed (prevents
# hourly schedule from redeploying the same commit on every tick).
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", "")
base_api = f"{server}/api/v1/repos/{repo}/actions"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
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"
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
]
# Walk runs newest-first; pick the first one where deploy-playstore
# actually ran (conclusion=success), not just skipped.
for run in runs:
run_id = run.get("id")
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Deploy to Play Store" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
print("")
print(runs[0].get("commit_sha") or "")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print(f"API check failed: {e}", file=sys.stderr)
print("")
PYEOF
)
if [ -z "$LAST_DEPLOYED_SHA" ]; then
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
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"
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$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. Deploy all targets when the
# SHA is not in local history (shallow clone or very old deploy).
if 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
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
echo "Changed files:"
echo "$CHANGED"
@@ -113,25 +75,13 @@ jobs:
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
if echo "$CHANGED" | grep -qE "$android_re"; then
echo "android=true" >> "$GITHUB_OUTPUT"
echo "Android deploy: TRIGGERED (android-relevant files changed)"
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "android=false" >> "$GITHUB_OUTPUT"
echo "Android deploy: SKIPPED (no android-relevant files changed)"
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
fi
echo "$CHANGED" | grep -qE "$android_re" \
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|| echo "android=false" >> "$GITHUB_OUTPUT"
if echo "$CHANGED" | grep -qE "$linux_re"; then
echo "linux=true" >> "$GITHUB_OUTPUT"
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "linux=false" >> "$GITHUB_OUTPUT"
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
fi
echo "$CHANGED" | grep -qE "$linux_re" \
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT"
deploy-playstore:
name: Build & Deploy to Play Store
@@ -149,23 +99,28 @@ jobs:
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
- name: Setup Dagger Remote Engine (via stunnel)
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
- name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1"
run: task publish-android
- name: Verify Play Store deployment
run: |
python3 -m venv /tmp/playstore-venv
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk:
name: Build & Deploy APK to Server
@@ -183,17 +138,31 @@ jobs:
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
- name: Setup Dagger Remote Engine (via stunnel)
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
- name: Build & Deploy APK to server
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 }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1"
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:
name: Build Linux Release
@@ -211,17 +180,71 @@ jobs:
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
- name: Setup Dagger Remote Engine (via stunnel)
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
- name: Build & Deploy Linux to server
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 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:
name: Update Deploy Health Label
+12 -2
View File
@@ -58,18 +58,28 @@ jobs:
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
- name: Setup Dagger Remote Engine (via stunnel)
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
- 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
- name: Create issue on test failure
if: failure()
env:
+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
+11 -2
View File
@@ -18,13 +18,22 @@ jobs:
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
- name: Setup Dagger Remote Engine (via stunnel)
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
- name: Run Renovate
env:
DAGGER_NO_NAG: "1"
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
run: task renovate
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+17 -5
View File
@@ -1,8 +1,6 @@
name: Update Website
on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push:
branches: [main]
paths:
@@ -26,18 +24,32 @@ jobs:
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
- name: Setup Dagger Remote Engine (via stunnel)
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
- name: Build & Update 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: Verify Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.1"
"flutter": "3.44.0"
}
+250
View File
@@ -0,0 +1,250 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
analyze-and-test:
name: Analyze & unit test
runs-on: sharedinbox-runner
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Check formatting
run: dart format --set-exit-if-changed .
- name: Analyze
run: flutter analyze --fatal-infos
- name: Unit + widget tests with coverage
run: flutter test test/unit/ test/widget/ --coverage
- name: Coverage gate
run: dart run scripts/check_coverage.dart
integration:
name: Integration tests (Stalwart)
runs-on: sharedinbox-runner
# Run integration tests only on push to main, not on every PR.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: DeterminateSystems/magic-nix-cache-action@v8
- name: Cache FVM Flutter SDK
uses: actions/cache@v4
with:
path: ~/.fvm
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Run integration tests
run: |
nix develop --command bash -c "
fvm install --skip-pub-get &&
fvm flutter pub get &&
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/test.sh
"
integration-ui:
name: UI Integration tests (Stalwart + Xvfb)
runs-on: sharedinbox-runner
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: DeterminateSystems/magic-nix-cache-action@v8
- name: Install Flutter Linux build dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- name: Cache FVM Flutter SDK
uses: actions/cache@v4
with:
path: ~/.fvm
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Cache Linux debug build
uses: actions/cache@v4
with:
path: |
build/linux
.dart_tool/flutter_build
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
restore-keys: linux-debug-
- name: Run UI integration tests
run: |
nix develop --command bash -c "
fvm install --skip-pub-get &&
fvm flutter pub get &&
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/integration_ui_test.sh
"
build-linux:
name: Build Linux desktop
runs-on: sharedinbox-runner
needs: analyze-and-test
steps:
- uses: actions/checkout@v4
- name: Install GTK3, build tools and libsecret
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Build Linux release
run: flutter build linux --release
deploy:
name: Deploy Linux build & publish website
runs-on: sharedinbox-runner
needs: build-linux
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
steps:
- uses: actions/checkout@v4
- name: Install build & deploy dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev hugo rsync
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 \
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
--date=short > assets/changelog.txt
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Build Linux release
run: |
HASH=$(git rev-parse --short HEAD)
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
- name: Deploy Linux build to server
run: |
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
"cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
- name: Generate build history pages
run: python3 scripts/generate_build_history.py
- name: Build website
env:
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
run: hugo --source website --minify
- name: Deploy website
run: |
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
-1
View File
@@ -1,6 +1,5 @@
# --- Flutter/Dart ---
coverage/
screenshots/
.dart_tool/
.dart-tool/
.packages
-6
View File
@@ -42,9 +42,3 @@ repos:
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'"
pass_filenames: false
always_run: true
- id: ci-image-exists
name: verify container images in ci/main.go are reachable
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
pass_filenames: false
files: ^ci/main\.go$
+32 -27
View File
@@ -8,41 +8,46 @@ CLI tool `fgj` is available to query issues/PRs/actions.
## 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 |
|---|---|---|
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
- **State/Planned** — Plan has been posted as a comment; awaiting human review
- **State/Ready** — Issue is approved and ready for implementation
- **State/InProgress** — Set while an agent (or human) is actively working
- **State/Question** — Agent hit a blocker or needs clarification
**State machine:**
Full lifecycle:
```
loop/plan loop/plan-in-progress → loop/plan-done
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-progress loop/code-done
↘ NeedSupervisor (on failure)
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
State/Planned → State/Ready (manual: human reviews the plan and approves)
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
State/InProgress → closed (automated: after PR is merged and CI passes)
any state → State/Question (automated or manual: when blocked)
```
**Rules:**
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
- Planning agents only post a comment — they do NOT write code or open PRs.
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
**Typical lifecycle for a new feature:**
List open issues ready to pick up:
```bash
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
```
1. Create issue
2. Add label loop/plan → agent writes plan as comment
3. Review plan, request changes or approve
4. Add label loop/code → agent implements + opens PR
5. Review PR, merge
6. Close issue
```
Rules:
- Never start implementation on an issue without `State/Ready`
- Planning agents only post a plan comment — they do NOT write code or open PRs
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
- When working via the agent loop: label transitions are set automatically
by `agent_loop.py` — do **not** set them yourself.
- When working manually: switch to `State/InProgress` as your **first action**:
```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
```
- 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:
```bash
fgj issue close <NUMBER>
```
## Code conventions
+1 -1
View File
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
# Replace 1003 with the actual UID of dagger-svc
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
Environment=XDG_RUNTIME_DIR=/run/user/1003
ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
Restart=always
[Install]
-2
View File
@@ -188,5 +188,3 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow
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
- **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
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
+8 -47
View File
@@ -218,7 +218,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
@@ -247,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh 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"
- 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"
deploy-apk:
desc: Build and deploy Android APK via Dagger
@@ -261,7 +261,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website:
desc: Build and publish website via Dagger
@@ -271,7 +271,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -294,11 +294,11 @@ tasks:
for attempt in 1 2 3; do
run_dagger "$@" && return 0
RC=$?
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|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
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
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
sleep 90
else
@@ -319,16 +319,7 @@ tasks:
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
}
trap cleanup EXIT
until [ -s "$PORTFILE" ]; do
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
until [ -s "$PORTFILE" ]; do sleep 0.05; done
PORT=$(cat "$PORTFILE")
retry_dagger env \
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
@@ -426,25 +417,6 @@ tasks:
fi
echo "Uploaded $TARBALL and updated latest.json"
deploy-bugreport:
desc: Build and deploy the Go bugreport server to the webserver
preconditions:
- sh: test -n "$SSH_USER"
msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
build-windows-release:
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
deps: [_pub-get, generate-changelog]
@@ -588,7 +560,7 @@ tasks:
run:
desc: Run the app on Linux desktop
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
deps: [_preflight, _linux-deps-check, _pub-get]
cmds:
- fvm flutter run -d linux --no-pub
@@ -719,11 +691,6 @@ tasks:
fi
echo "Hygiene check passed."
check-ci-images:
desc: Verify that all container images referenced in ci/main.go are reachable
cmds:
- scripts/check_ci_images.sh
_integrations:
internal: true
run: once
@@ -736,12 +703,6 @@ tasks:
cmds:
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
screenshots:
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
deps: [_preflight, _codegen]
cmds:
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test]
+2 -4
View File
@@ -16,10 +16,8 @@ android {
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
signingConfigs {
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+49 -1
View File
@@ -2,4 +2,52 @@ module dagger/ci
go 1.26.2
require golang.org/x/sync v0.20.0
require (
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
github.com/Khan/genqlient v0.8.1
github.com/dagger/otel-go v1.43.0
github.com/vektah/gqlparser/v2 v2.5.33
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)
require (
github.com/99designs/gqlgen v0.17.90 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
go.opentelemetry.io/otel/log v0.17.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.3 // 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/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
+95
View File
@@ -1,2 +1,97 @@
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+27 -73
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"dagger/ci/internal/dagger"
"encoding/json"
"fmt"
"time"
@@ -149,33 +148,16 @@ if __name__ == "__main__":
`
type Ci struct {
Source *dagger.Directory
FlutterVersion string
Source *dagger.Directory
}
func New(
ctx context.Context,
// +defaultPath=".."
source *dagger.Directory,
) (*Ci, error) {
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
}
var fvmrc struct {
Flutter string `json:"flutter"`
}
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
}
if fvmrc.Flutter == "" {
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
}
) *Ci {
return &Ci{
FlutterVersion: fvmrc.Flutter,
Source: source.Filter(dagger.DirectoryFilterOpts{
Include: []string{
".fvmrc",
"lib/",
"test/",
"assets/",
@@ -191,7 +173,7 @@ func New(
"website/",
},
}),
}, nil
}
}
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
@@ -199,7 +181,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
@@ -356,17 +338,7 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
// Create .ssh with strict permissions before Dagger mounts anything there,
// so the directory is 700 (not Dagger's default 755).
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
// Mount the raw key outside .ssh so Dagger cannot override the directory
// permissions we just set above.
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
// Using Python3 (not tr) changes the Dagger cache key so stale cached
// results from the old tr-based step are not reused.
WithExec([]string{"python3", "-c",
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
}
@@ -440,11 +412,11 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
Stdout(ctx)
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It snapshots the committed source (including any stale generated files) before
// CheckMocks verifies that generated mocks are up to date.
// It snapshots the committed source (including any stale *.mocks.dart) before
// running build_runner, so git diff detects real staleness instead of always
// comparing two freshly-generated outputs.
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
return m.pubGetLayer().
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
@@ -457,16 +429,16 @@ func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
Stdout(ctx)
}
// Coverage runs unit and widget tests with coverage gate.
// Coverage runs unit tests with coverage gate.
func (m *Ci) Coverage(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
Stdout(ctx)
@@ -508,18 +480,11 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
// Run cheap structural checks in parallel for faster fail detection.
var fastEg errgroup.Group
fastEg.Go(func() error {
_, err := m.CheckHygiene(ctx)
return err
})
fastEg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
if err := fastEg.Wait(); err != nil {
return "", err
if _, err := m.CheckHygiene(ctx); err != nil {
return "Hygiene check failed", err
}
if _, err := m.CheckLayers(ctx); err != nil {
return "Layer check failed", err
}
checkSetup := m.setup(m.checkSrc())
@@ -533,7 +498,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return analyze, err
}
mocks, err := m.CheckGenerated(ctx)
mocks, err := m.CheckMocks(ctx)
if err != nil {
return mocks, err
}
@@ -543,19 +508,16 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return coverage, err
}
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
// sibling via context — which would surface as "context canceled" in dagger
// output and trigger spurious retries in check-dagger.
var testBackend, testIntegration string
var eg errgroup.Group
eg, egCtx := errgroup.WithContext(ctx)
eg.Go(func() error {
var e error
testBackend, e = m.TestBackend(ctx)
testBackend, e = m.TestBackend(egCtx)
return e
})
eg.Go(func() error {
var e error
testIntegration, e = m.TestIntegration(ctx)
testIntegration, e = m.TestIntegration(egCtx)
return e
})
if err := eg.Wait(); err != nil {
@@ -597,8 +559,6 @@ func (m *Ci) BuildWebsite(
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
// +optional
commitHash string,
) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
@@ -606,13 +566,9 @@ func (m *Ci) BuildWebsite(
Include: []string{"website/"},
}).WithDirectory("website/content/builds", buildHistory)
hugo := m.Hugo().
return m.Hugo().
WithDirectory("/src", websiteSource).
WithWorkdir("/src/website")
if commitHash != "" {
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
}
return hugo.
WithWorkdir("/src/website").
WithExec([]string{"hugo", "--minify"}).
Directory("public")
}
@@ -624,10 +580,8 @@ func (m *Ci) PublishWebsite(
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
// +optional
commitHash string,
) (string, error) {
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
return m.Deployer(sshKey, knownHosts).
WithDirectory("/public", public).
@@ -920,12 +874,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
//
// dagger call --progress=plain -q -m ci --source=. graph
func (m *Ci) Graph() string {
return fmt.Sprintf(`# CI Pipeline Graph
return `# CI Pipeline Graph
`+"```"+`mermaid
` + "```" + `mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
@@ -935,7 +889,7 @@ flowchart TD
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
codegen --> fmt["Format"]
codegen --> analyze["Analyze"]
-12
View File
@@ -4,18 +4,6 @@ This file contains tasks which got implemented.
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
-1
View File
@@ -99,7 +99,6 @@
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
]);
shellHook = ''
-25
View File
@@ -1,25 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
<!-- White Background -->
<rect width="512" height="512" fill="white"/>
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
<!-- Red -->
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
<!-- Orange -->
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
<!-- Yellow -->
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
<!-- Green -->
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
<!-- Blue -->
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
<!-- Purple -->
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 38;
const int dbSchemaVersion = 36;
-17
View File
@@ -2,30 +2,13 @@ enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
enum PrefetchMode {
disabled,
wifiOnly,
always;
static PrefetchMode fromString(String? value) {
return PrefetchMode.values.firstWhere(
(e) => e.name == value,
orElse: () => PrefetchMode.wifiOnly,
);
}
}
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
this.prefetchMode = PrefetchMode.wifiOnly,
this.bodyCacheLimitMb = 100,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
final PrefetchMode prefetchMode;
final int bodyCacheLimitMb;
}
@@ -15,10 +15,6 @@ abstract class EmailRepository {
int limit = 50,
});
/// Returns threads from the INBOX mailbox of every account, sorted by latest
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
/// Returns all emails belonging to [threadId] in [mailboxPath].
Stream<List<Email>> observeEmailsInThread(
String accountId,
@@ -5,10 +5,4 @@ abstract class UserPreferencesRepository {
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Future<void> updatePrefetchMode(PrefetchMode mode);
Future<void> updateBodyCacheLimitMb(int mb);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
Future<void> removeTrustedImageSender(String senderEmail);
}
-82
View File
@@ -1,82 +0,0 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
/// Prefetches email bodies in the background and enforces a local cache size
/// limit by evicting the oldest cached bodies when the limit is exceeded.
class BodyCacheService {
BodyCacheService(this._db, this._accountRepo);
final AppDatabase _db;
final AccountRepository _accountRepo;
static const _batchSize = 20;
Future<void> run() async {
final prefs = await (_db.select(
_db.userPreferences,
)).getSingleOrNull();
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
final limitBytes = limitMb * 1024 * 1024;
await _evictIfNeeded(limitBytes);
final candidates = await _fetchCandidates();
if (candidates.isEmpty) return;
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
for (final emailId in candidates) {
final currentSize = await _getCacheSizeBytes();
if (currentSize >= limitBytes) break;
try {
await emailRepo.getEmailBody(emailId);
} catch (_) {
// Skip emails that fail to fetch.
}
}
}
Future<void> _evictIfNeeded(int limitBytes) async {
final currentSize = await _getCacheSizeBytes();
if (currentSize <= limitBytes) return;
final bodies = await (_db.select(_db.emailBodies)
..where((t) => t.cachedAt.isNotNull())
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
.get();
var remaining = currentSize;
for (final body in bodies) {
if (remaining <= limitBytes) break;
final bodySize =
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
await (_db.delete(_db.emailBodies)
..where((t) => t.emailId.equals(body.emailId)))
.go();
remaining -= bodySize;
}
}
Future<int> _getCacheSizeBytes() async {
final result = await _db
.customSelect(
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
)
.getSingle();
return result.read<int>('total');
}
Future<List<String>> _fetchCandidates() async {
final rows = await _db.customSelect(
'SELECT e.id FROM emails e '
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
'WHERE eb.email_id IS NULL '
'ORDER BY e.received_at DESC '
'LIMIT ?',
variables: [Variable.withInt(_batchSize)],
).get();
return rows.map((r) => r.read<String>('id')).toList();
}
}
@@ -92,9 +92,8 @@ class ShareEncryptionService {
) {
if (!s.startsWith(_pubKeyPrefix)) return null;
try {
final data = Uint8List.fromList(
base64.decode(s.substring(_pubKeyPrefix.length)),
);
final data =
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
if (data.length != _keyIdLen + _pubKeyLen) return null;
return (
keyId: data.sublist(0, _keyIdLen),
+2 -3
View File
@@ -108,9 +108,8 @@ class SieveInterpreter {
}
bool _globMatch(String value, String pattern) {
final regexStr = RegExp.escape(
pattern,
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
final regexStr =
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value);
}
+9 -3
View File
@@ -466,7 +466,9 @@ class _Scanner {
String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException('Expected tagged argument at position $_pos');
throw SieveParseException(
'Expected tagged argument at position $_pos',
);
}
String? peekSizeUnit() {
@@ -478,7 +480,9 @@ class _Scanner {
String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException('Expected number at position $_pos');
throw SieveParseException(
'Expected number at position $_pos',
);
}
final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) {
@@ -489,7 +493,9 @@ class _Scanner {
String readQuotedString() {
if (_src[_pos] != '"') {
throw SieveParseException('Expected " at position $_pos');
throw SieveParseException(
'Expected " at position $_pos',
);
}
_pos++; // skip opening quote
final buf = StringBuffer();
+2 -50
View File
@@ -11,9 +11,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/services/body_cache_service.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
@@ -23,7 +21,6 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
import 'package:workmanager/workmanager.dart';
const _kTaskName = 'si_bg_sync';
const _kPrefetchTaskName = 'si_bg_prefetch';
const _kResourceType = 'background_check';
@pragma('vm:entry-point')
@@ -31,13 +28,9 @@ void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((taskName, __) async {
Workmanager().executeTask((_, __) async {
try {
if (taskName == _kPrefetchTaskName) {
await _doBodyPrefetch();
} else {
await _doBackgroundSync();
}
await _doBackgroundSync();
} catch (_) {}
return true;
});
@@ -62,31 +55,6 @@ Future<void> registerBackgroundSync() async {
}
}
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
/// Call on app startup and whenever the user changes the prefetch preference.
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
try {
if (mode == PrefetchMode.disabled) {
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
return;
}
final networkType = mode == PrefetchMode.wifiOnly
? NetworkType.unmetered
: NetworkType.connected;
await Workmanager().registerPeriodicTask(
_kPrefetchTaskName,
_kPrefetchTaskName,
frequency: const Duration(hours: 1),
constraints: Constraints(networkType: networkType),
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
);
} on PlatformException {
// Ignore — WorkManager unavailable.
} on MissingPluginException {
// Ignore — plugin not registered.
} catch (_) {}
}
Future<void> _doBackgroundSync() async {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
@@ -108,22 +76,6 @@ Future<void> _doBackgroundSync() async {
}
}
Future<void> _doBodyPrefetch() async {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
);
try {
final accountRepo = AccountRepositoryImpl(
db,
const FlutterSecureStorageImpl(),
);
await BodyCacheService(db, accountRepo).run();
} finally {
await db.close();
}
}
Future<void> _checkAccount(
AppDatabase db,
AccountRepository accountRepo,
+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.toLowerCase()}"', 'src="$dataUri"')
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
.replaceAll(
"src='cid:${bareCid.toLowerCase()}'",
"src='$dataUri'",
);
}
return result;
}
-28
View File
@@ -307,17 +307,6 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId};
}
/// Senders for whom remote images are loaded automatically.
/// Per-device/per-user — not tied to any email account.
@DataClassName('ImageTrustedSenderRow')
class ImageTrustedSenders extends Table {
TextColumn get senderEmail => text()();
DateTimeColumn get addedAt => dateTime()();
@override
Set<Column> get primaryKey => {senderEmail};
}
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
@@ -330,12 +319,6 @@ class UserPreferences extends Table {
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
TextColumn get prefetchMode =>
text().withDefault(const Constant('wifiOnly'))();
// Added in schema v38: max cache size for offline email bodies, in megabytes.
IntColumn get bodyCacheLimitMb =>
integer().withDefault(const Constant(100))();
@override
Set<Column> get primaryKey => {id};
@@ -362,7 +345,6 @@ class UserPreferences extends Table {
LocalSieveApplied,
ShareKeys,
UserPreferences,
ImageTrustedSenders,
],
)
class AppDatabase extends _$AppDatabase {
@@ -629,16 +611,6 @@ class AppDatabase extends _$AppDatabase {
userPreferences.afterMailViewAction,
);
}
if (from < 37) {
await m.createTable(imageTrustedSenders);
}
if (from >= 34 && from < 38) {
await m.addColumn(userPreferences, userPreferences.prefetchMode);
await m.addColumn(
userPreferences,
userPreferences.bodyCacheLimitMb,
);
}
},
);
}
+16 -11
View File
@@ -9,9 +9,8 @@ class LocalSieveRepository {
final AppDatabase _db;
Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select(
_db.localSieveScripts,
)..where((t) => t.accountId.equals(accountId)))
final rows = await (_db.select(_db.localSieveScripts)
..where((t) => t.accountId.equals(accountId)))
.get();
return rows
.map(
@@ -27,9 +26,10 @@ class LocalSieveRepository {
Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId);
final row = await (_db.select(
_db.localSieveScripts,
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
final row = await (_db.select(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId');
return row.content;
@@ -44,7 +44,9 @@ class LocalSieveRepository {
if (id != null) {
final rowId = int.parse(id);
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(
LocalSieveScriptsCompanion(
name: Value(name),
@@ -76,9 +78,10 @@ class LocalSieveRepository {
Future<void> deleteScript(String accountId, String scriptId) async {
final rowId = int.parse(scriptId);
await (_db.delete(
_db.localSieveScripts,
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
await (_db.delete(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.go();
}
@@ -89,7 +92,9 @@ class LocalSieveRepository {
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
final rowId = int.parse(scriptId);
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)));
});
}
@@ -9,8 +9,11 @@ import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
: _imapConnect = imapConnect;
DraftRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db;
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.
try {
await client.createMailbox('Drafts');
@@ -156,9 +162,8 @@ class DraftRepositoryImpl implements DraftRepository {
? uidList.first.toString()
: null;
if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
DraftsCompanion(imapServerId: Value(uid)),
);
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
.write(DraftsCompanion(imapServerId: Value(uid)));
}
}
@@ -95,26 +95,6 @@ class EmailRepositoryImpl implements EmailRepository {
.map((rows) => rows.map(_threadRowToModel).toList());
}
@override
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
final query = _db.select(_db.threads).join([
innerJoin(
_db.mailboxes,
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
),
]);
query
..where(_db.mailboxes.role.equals('inbox'))
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
..limit(limit);
return query.watch().map(
(rows) => rows
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
.toList(),
);
}
model.EmailThread _threadRowToModel(ThreadRow row) {
List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
@@ -176,7 +156,6 @@ class EmailRepositoryImpl implements EmailRepository {
return;
}
if (threadEmails.isEmpty) return;
final latest = threadEmails.last;
// Collect unique participants across the whole thread.
@@ -258,12 +237,7 @@ class EmailRepositoryImpl implements EmailRepository {
try {
await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
final msg = fetch.messages.firstOrNull;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final msg = fetch.messages.first;
final textBody = msg.decodeTextPlainPart();
final rawHtml = msg.decodeTextHtmlPart();
final htmlBody =
@@ -351,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
],
'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true,
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
'bodyProperties': [
'partId',
'type',
'name',
'size',
'subParts',
],
},
'0',
],
@@ -1969,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
.getSingleOrNull();
final inboxPath = inboxMailbox?.path ?? 'INBOX';
final alreadyApplied = await (_db.select(
_db.localSieveApplied,
)..where((t) => t.accountId.equals(accountId)))
final alreadyApplied = await (_db.select(_db.localSieveApplied)
..where((t) => t.accountId.equals(accountId)))
.get();
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
@@ -2071,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(1))
.getSingleOrNull();
if (destMailbox == null) {
log(
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
);
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
return;
}
destPath = destMailbox.path;
@@ -2831,13 +2808,11 @@ class EmailRepositoryImpl implements EmailRepository {
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
// A partial BODY.PEEK[n] fetch omits those headers, causing
// decodeContentBinary() to return raw base64 instead of decoded bytes.
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
final msg = fetch.messages.firstOrNull;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final fetch = await client.uidFetchMessage(
emailRow.uid,
'BODY.PEEK[]',
);
final msg = fetch.messages.first;
final part = msg.getPart(attachment.fetchPartId) ?? msg;
final bytes = part.decodeContentBinary();
if (bytes == null) {
@@ -2899,14 +2874,11 @@ class EmailRepositoryImpl implements EmailRepository {
);
try {
await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
final msg = fetch.messages.firstOrNull;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
return msg.renderMessage();
final fetch = await client.uidFetchMessage(
emailRow.uid,
'BODY.PEEK[]',
);
return fetch.messages.first.renderMessage();
} finally {
await client.logout();
}
@@ -2983,20 +2955,6 @@ class EmailRepositoryImpl implements EmailRepository {
}) async {
if (query.length < 2) return [];
final pattern = '%${query.toLowerCase()}%';
// Addresses we deliberately wrote to (sent folder) should appear before
// addresses that happened to email us (inbox/other folders).
final sentMailboxes = await (_db.select(_db.mailboxes)
..where((t) {
Expression<bool> cond = t.role.equals('sent');
if (accountId != null) {
cond = t.accountId.equals(accountId) & cond;
}
return cond;
}))
.get();
final sentPaths = {for (final m in sentMailboxes) m.path};
final rows = await (_db.select(_db.emails)
..where((t) {
Expression<bool> cond = const Constant(true);
@@ -3011,22 +2969,11 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(100))
.get();
// Two passes: sent-folder rows first (prioritise recipients we chose),
// then other rows (senders who contacted us).
final sortedRows = [
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
];
final seen = <String>{};
final results = <model.EmailAddress>[];
final lowerQuery = query.toLowerCase();
for (final row in sortedRows) {
final isSent = sentPaths.contains(row.mailboxPath);
final fields = isSent
? [row.toAddresses, row.ccJson, row.fromJson]
: [row.fromJson, row.toAddresses, row.ccJson];
for (final jsonStr in fields) {
for (final row in rows) {
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
final list = jsonDecode(jsonStr) as List<dynamic>;
for (final e in list) {
final map = e as Map<String, dynamic>;
@@ -3305,17 +3252,14 @@ class EmailRepositoryImpl implements EmailRepository {
await _db.customStatement('PRAGMA foreign_keys = OFF');
try {
await _db.transaction(() async {
await (_db.delete(
_db.emails,
)..where((t) => t.accountId.equals(accountId)))
await (_db.delete(_db.emails)
..where((t) => t.accountId.equals(accountId)))
.go();
await (_db.delete(
_db.pendingChanges,
)..where((t) => t.accountId.equals(accountId)))
await (_db.delete(_db.pendingChanges)
..where((t) => t.accountId.equals(accountId)))
.go();
await (_db.delete(
_db.syncStates,
)..where((t) => t.accountId.equals(accountId)))
await (_db.delete(_db.syncStates)
..where((t) => t.accountId.equals(accountId)))
.go();
});
} finally {
@@ -82,9 +82,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
// 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)))
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};
@@ -321,9 +320,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {
await (_db.delete(
_db.mailboxes,
)..where((t) => t.accountId.equals(accountId)))
await (_db.delete(_db.mailboxes)
..where((t) => t.accountId.equals(accountId)))
.go();
}
@@ -369,9 +367,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id)))
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
@@ -423,9 +419,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(dbId)))
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 {
// Remove existing entry for same query (deduplication).
await (_db.delete(
_db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed)))
await (_db.delete(_db.searchHistoryEntries)
..where((t) => t.query.equals(trimmed)))
.go();
await _db.into(_db.searchHistoryEntries).insert(
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
.get();
if (keepIds.isNotEmpty) {
await (_db.delete(
_db.searchHistoryEntries,
)..where((t) => t.id.isNotIn(keepIds)))
await (_db.delete(_db.searchHistoryEntries)
..where((t) => t.id.isNotIn(keepIds)))
.go();
}
});
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
await _pruneExpired();
final keyIdHex = _hex(keyId);
final row = await (_db.select(
_db.shareKeys,
)..where((t) => t.id.equals(keyIdHex)))
final row = await (_db.select(_db.shareKeys)
..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull();
if (row == null) return null;
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
}
Future<void> _pruneExpired() async {
await (_db.delete(
_db.shareKeys,
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
await (_db.delete(_db.shareKeys)
..where(
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
))
.go();
}
@@ -11,9 +11,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@@ -50,51 +48,6 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
);
}
@override
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
prefetchMode: Value(mode.name),
),
);
}
@override
Future<void> updateBodyCacheLimitMb(int mb) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
bodyCacheLimitMb: Value(mb),
),
);
}
@override
Stream<List<String>> observeTrustedImageSenders() {
return (_db.select(_db.imageTrustedSenders)
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
.watch()
.map((rows) => rows.map((r) => r.senderEmail).toList());
}
@override
Future<void> addTrustedImageSender(String senderEmail) async {
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
ImageTrustedSendersCompanion(
senderEmail: Value(senderEmail.toLowerCase()),
addedAt: Value(DateTime.now()),
),
);
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
await (_db.delete(_db.imageTrustedSenders)
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
.go();
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences();
return pref.UserPreferences(
@@ -110,8 +63,6 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
bodyCacheLimitMb: row.bodyCacheLimitMb,
);
}
}
+10 -51
View File
@@ -101,9 +101,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider));
});
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
ref,
) {
final searchHistoryRepositoryProvider =
Provider<SearchHistoryRepository>((ref) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
@@ -136,10 +135,8 @@ final syncHealthProvider =
.watchSingleOrNull();
});
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
ref,
accountId,
) {
final isSyncingProvider =
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
@@ -188,9 +185,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
});
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
UndoService.new,
);
final undoServiceProvider =
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -211,38 +207,10 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
repo.getEmailBody(_emailId),
]);
unawaited(repo.setFlag(_emailId, seen: true));
final header = results[0] as Email?;
if (header != null) {
unawaited(_prefetchNextEmailBody(repo, header));
}
return (results[0] as Email?, results[1] as EmailBody);
}
Future<void> _prefetchNextEmailBody(
EmailRepository repo,
Email header,
) async {
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return;
final threads =
await repo.observeThreads(header.accountId, header.mailboxPath).first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(_emailId),
);
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
final nextId = threads[currentIndex + 1].latestEmailId;
await repo.getEmailBody(nextId);
}
}
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
return ref.watch(accountRepositoryProvider).observeAccounts();
});
final accountByIdProvider =
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref.watch(accountRepositoryProvider).observeAccounts().map(
@@ -264,21 +232,12 @@ final accountConnectionStatusProvider =
.testConnection(account, password);
});
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
ref,
) {
final userPreferencesRepositoryProvider =
Provider<UserPreferencesRepository>((ref) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
ref,
) {
final userPreferencesProvider =
StreamProvider.autoDispose<UserPreferences>((ref) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
final trustedImageSendersProvider =
StreamProvider.autoDispose<List<String>>((ref) {
return ref
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
+5 -32
View File
@@ -5,30 +5,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/router.dart';
import 'package:sharedinbox/ui/screens/crash_screen.dart';
import 'package:stack_trace/stack_trace.dart' as stack_trace;
void main({List<Override> overrides = const []}) {
void main({List<Override> overrides = const []}) async {
unawaited(
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Dart's async machinery propagates stack traces in chain format
// (with '===== asynchronous gap =====' separators). Flutter's
// StackFrame parser asserts on those lines, so strip them first.
FlutterError.demangleStackTrace = (StackTrace s) {
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
if (s is stack_trace.Trace) return s.vmTrace;
return s;
};
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
ErrorWidget.builder = (details) => CrashScreen(
exception: details.exception,
@@ -50,35 +39,19 @@ void main({List<Override> overrides = const []}) {
if (Platform.isAndroid) {
await initNotifications();
await registerBackgroundSync();
await _registerPrefetchTaskFromStoredPrefs();
}
runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
);
},
// This handler runs in the parent zone — runApp cannot be called here.
// Framework errors are already handled by FlutterError.onError above.
(error, stack) => FlutterError.reportError(
FlutterErrorDetails(exception: error, stack: stack),
),
(error, stack) {
// Catch unhandled async errors.
runApp(CrashScreen(exception: error, stackTrace: stack));
},
),
);
}
/// Reads the stored prefetch preference and registers the WorkManager task
/// with the correct network constraint for it. Opens and immediately closes
/// a temporary DB connection; safe because initDatabasePath() has already run.
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
final db = AppDatabase();
try {
final row = await db.select(db.userPreferences).getSingleOrNull();
final mode = PrefetchMode.fromString(row?.prefetchMode);
await registerBodyPrefetchTask(mode);
} finally {
await db.close();
}
}
class SharedInboxApp extends ConsumerStatefulWidget {
const SharedInboxApp({super.key});
+1 -13
View File
@@ -8,9 +8,7 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
@@ -26,15 +24,11 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
final router = GoRouter(
initialLocation: '/inbox',
initialLocation: '/accounts',
routes: [
ShellRoute(
builder: (ctx, state, child) => UndoShell(child: child),
routes: [
GoRoute(
path: '/inbox',
builder: (ctx, state) => const CombinedInboxScreen(),
),
GoRoute(
path: '/accounts',
builder: (ctx, state) => const AccountListScreen(),
@@ -170,12 +164,6 @@ final router = GoRouter(
);
},
),
GoRoute(
path: '/bug-report',
builder: (ctx, state) => BugReportScreen(
emailId: state.uri.queryParameters['emailId'],
),
),
],
),
],
+12 -23
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
@@ -73,10 +72,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
final launched =
await launchUrl(url, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -124,10 +121,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
);
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
final launched =
await launchUrl(url, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -181,7 +176,9 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
selectable: true,
onTapLink: (text, href, title) {
if (href != null) {
unawaited(_launchUrl(context, Uri.parse(href)));
unawaited(
_launchUrl(context, Uri.parse(href)),
);
}
},
);
@@ -198,30 +195,22 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy info'),
label: const Text('Copy to clipboard'),
onPressed: () => unawaited(
_copyToClipboard(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 4),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Public issue'),
child: FilledButton.icon(
icon: const Icon(Icons.bug_report),
label: const Text('Create issue'),
onPressed: () => unawaited(
_createIssue(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 4),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.feedback_outlined),
label: const Text('Report bug'),
onPressed: () => context.push('/bug-report'),
),
),
],
),
),
+70 -73
View File
@@ -120,76 +120,15 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Text('${account.email}\n$typeLabel'),
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'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4),
health.when(
data: (h) {
if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
@@ -202,7 +141,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange,
),
const SizedBox(width: 4),
Expanded(
Flexible(
child: Text(
h.isHealthy
? 'Healthy'
@@ -216,8 +155,66 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'),
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'),
);
}
+5 -1
View File
@@ -219,7 +219,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
),
),
_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(
child: Padding(
+7 -2
View File
@@ -158,7 +158,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
for (final account in selected) {
final password = await repo.getPassword(account.id);
payloads.add(
AccountPayload(accountJson: account.toJson(), password: password),
AccountPayload(
accountJson: account.toJson(),
password: password,
),
);
}
@@ -358,7 +361,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Encrypted code copied to clipboard'),
content: Text(
'Encrypted code copied to clipboard',
),
),
);
},
-635
View File
@@ -1,635 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
const _bugReportApiUrl = String.fromEnvironment(
'BUG_REPORT_API_URL',
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
);
class BugReportScreen extends ConsumerStatefulWidget {
const BugReportScreen({super.key, this.emailId});
final String? emailId;
@override
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
}
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _emailController = TextEditingController();
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture = getDeviceModel();
final List<PlatformFile> _attachments = [];
bool _includeEmail = false;
bool _includeSyncLog = false;
bool _submitting = false;
Email? _attachedEmail;
List<Account> _accounts = [];
String? _selectedAccountId;
String? _deviceModel;
bool _loadingEmail = false;
@override
void initState() {
super.initState();
unawaited(_loadInitialData());
}
@override
void dispose() {
_descriptionController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _loadInitialData() async {
setState(() => _loadingEmail = true);
try {
_deviceModel = await _deviceModelFuture;
_accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (widget.emailId != null) {
final email =
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
if (mounted && email != null) {
_attachedEmail = email;
_selectedAccountId = email.accountId;
final fromStr =
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
final subjectStr = email.subject ?? '(no subject)';
_descriptionController.text =
'Problem with email from $fromStr: "$subjectStr"\n\n';
}
}
if (_selectedAccountId == null && _accounts.isNotEmpty) {
_selectedAccountId = _accounts.first.id;
}
if (_selectedAccountId != null) {
final matching =
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
if (matching != null) {
_emailController.text = matching.email;
}
}
} catch (_) {}
if (mounted) {
setState(() => _loadingEmail = false);
}
}
int get _totalAttachmentSize {
return _attachments.fold(0, (sum, f) => sum + f.size);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
Future<void> _pickAttachments() async {
try {
final result = await FilePicker.pickFiles();
if (result == null) return;
final newFiles =
result.files.where((PlatformFile f) => f.path != null).toList();
if (!mounted) return;
setState(() {
_attachments.addAll(newFiles);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick files: $e')),
);
}
}
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
}
String _serializeSyncLogs(List<SyncLogEntry> entries) {
final sb = StringBuffer();
for (final entry in entries.take(50)) {
sb.writeln('ID: ${entry.id}');
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
sb.writeln('Result: ${entry.result}');
if (entry.errorMessage != null) {
sb.writeln('Error: ${entry.errorMessage}');
}
if (entry.stackTrace != null) {
sb.writeln('StackTrace:\n${entry.stackTrace}');
}
sb.writeln('Protocol: ${entry.protocol}');
sb.writeln(
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
);
if (entry.protocolLog != null) {
sb.writeln('Protocol Log:\n${entry.protocolLog}');
}
sb.writeln('---');
}
return sb.toString();
}
Future<void> _submitReport() async {
if (!_formKey.currentState!.validate()) return;
final totalSize = _totalAttachmentSize;
if (totalSize > 20 * 1024 * 1024) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
),
backgroundColor: Colors.red,
),
);
return;
}
setState(() => _submitting = true);
try {
final client = ref.read(httpClientProvider);
final uri = Uri.parse(_bugReportApiUrl);
final request = http.MultipartRequest('POST', uri);
// Description
request.fields['description'] = _descriptionController.text;
// Email Data if from email view
if (_attachedEmail != null) {
final emailMap = {
'id': _attachedEmail!.id,
'subject': _attachedEmail!.subject,
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
'date': _attachedEmail!.sentAt?.toIso8601String() ??
_attachedEmail!.receivedAt.toIso8601String(),
'preview': _attachedEmail!.preview,
};
request.fields['email_data'] = jsonEncode(emailMap);
}
// Contact Email
if (_includeEmail) {
request.fields['email'] = _emailController.text;
}
// About Info
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
final imapCount =
_accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount =
_accounts.where((a) => a.type == AccountType.jmap).length;
if (!mounted) return;
final aboutInfo = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
request.fields['about_info'] = aboutInfo;
// Sync Log
if (_includeSyncLog && _selectedAccountId != null) {
final syncLogs = await ref
.read(syncLogRepositoryProvider)
.observeSyncLogs(_selectedAccountId!)
.first;
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
}
// Attachments
for (final file in _attachments) {
final multipartFile = await http.MultipartFile.fromPath(
'attachments[]',
file.path!,
filename: file.name,
);
request.files.add(multipartFile);
}
final streamedResponse = await client.send(request);
final response = await http.Response.fromStream(streamedResponse);
if (!mounted) return;
if (response.statusCode == 201) {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
final reportId = resData['id'] as String;
_showSuccessDialog(reportId);
} else if (response.statusCode == 429) {
final retryAfter = response.headers['retry-after'] ?? '6';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
backgroundColor: Colors.orange,
),
);
} else {
String errorMsg =
'Failed to submit report. Server returned status: ${response.statusCode}';
try {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
if (resData['error'] != null) {
errorMsg = resData['error'] as String;
}
} catch (_) {}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An error occurred: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
void _showSuccessDialog(String reportId) {
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Bug Report Submitted'),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('Thank you for helping us improve SharedInbox!'),
const SizedBox(height: 12),
Text(
'Your Report ID is:\n$reportId',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your report is handled confidentially and has not been posted to the public issue tracker.',
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Dismiss dialog
context.pop(); // Go back to previous screen
},
child: const Text('Close'),
),
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalSize = _totalAttachmentSize;
const sizeLimit = 20 * 1024 * 1024;
final approachingLimit = totalSize > 15 * 1024 * 1024;
return Scaffold(
appBar: AppBar(
title: const Text('Report a Bug'),
),
body: _loadingEmail
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Confidentiality info card
Card(
elevation: 0,
color: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
side: BorderSide(
color:
theme.colorScheme.secondary.withValues(alpha: 0.4),
),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.lock_outline,
color: theme.colorScheme.secondary,
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Your report is handled confidentially and will not be posted to the public issue tracker.',
style: TextStyle(height: 1.3),
),
),
],
),
),
),
const SizedBox(height: 20),
// Description Text Field
TextFormField(
controller: _descriptionController,
autofocus: true,
maxLines: 8,
minLines: 4,
decoration: const InputDecoration(
labelText: 'What went wrong?',
alignLabelWithHint: true,
border: OutlineInputBorder(),
helperText:
'Please describe the problem and how to reproduce it.',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a description.';
}
return null;
},
),
const SizedBox(height: 20),
// Email info chip if email is attached
if (_attachedEmail != null) ...[
Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: Row(
children: [
Icon(
Icons.email_outlined,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'The current email metadata will be attached automatically.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Attachments Section
Text(
'Attachments',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickAttachments,
icon: const Icon(Icons.add_a_photo_outlined),
label: const Text('Add screenshots'),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Screenshots help us understand the problem faster.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
),
if (_attachments.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _attachments.length,
itemBuilder: (context, index) {
final file = _attachments[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: InputChip(
label: Text(
'${file.name} (${_formatSize(file.size)})',
),
onDeleted: _submitting
? null
: () => _removeAttachment(index),
),
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
style: TextStyle(
fontSize: 12,
color: totalSize > sizeLimit
? Colors.red
: approachingLimit
? Colors.orange
: Colors.grey,
fontWeight: approachingLimit
? FontWeight.bold
: FontWeight.normal,
),
),
if (totalSize > sizeLimit) ...[
const SizedBox(width: 8),
const Icon(
Icons.error_outline,
size: 16,
color: Colors.red,
),
],
],
),
],
const SizedBox(height: 24),
// Email opt-in
CheckboxListTile(
title: const Text('Include my email for follow-up'),
value: _includeEmail,
onChanged: _submitting
? null
: (val) {
setState(() => _includeEmail = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
if (_includeEmail) ...[
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Contact Email Address',
border: OutlineInputBorder(),
),
validator: (value) {
if (_includeEmail &&
(value == null || value.trim().isEmpty)) {
return 'Please enter an email address.';
}
return null;
},
),
),
],
// Sync log opt-in
if (_selectedAccountId != null) ...[
CheckboxListTile(
title: const Text('Include recent sync log'),
subtitle: const Text(
'Helps diagnose connection and protocol issues.',
),
value: _includeSyncLog,
onChanged: _submitting
? null
: (val) {
setState(() => _includeSyncLog = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 12),
],
// System info section
FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
final imapCount = _accounts
.where((a) => a.type == AccountType.imap)
.length;
final jmapCount = _accounts
.where((a) => a.type == AccountType.jmap)
.length;
final aboutMd = buildAboutMarkdown(
context: context,
pkg: snapshot.data,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.1),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpansionTile(
title: const Text(
'System Info (attached automatically)',
style: TextStyle(fontSize: 14),
),
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Align(
alignment: Alignment.topLeft,
child: MarkdownBody(data: aboutMd),
),
),
],
),
);
},
),
const SizedBox(height: 32),
// Submit Button
FilledButton(
onPressed: _submitting ? null : _submitReport,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Send Bug Report',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
}
+2 -3
View File
@@ -12,9 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future: DefaultAssetBundle.of(
context,
).loadString('assets/changelog.txt'),
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
-393
View File
@@ -1,393 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@override
ConsumerState<CombinedInboxScreen> createState() =>
_CombinedInboxScreenState();
}
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
return accountsAsync.when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
body: Center(child: Text('Error: $e')),
),
data: (accounts) {
if (accounts.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) context.go('/accounts');
});
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final accountNames = {
for (final a in accounts) a.id: a.displayName,
};
final showAccount = accounts.length > 1;
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _buildDrawer(context, accounts),
body: _buildBody(accountNames, showAccount),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
return AppBar(
title: const Text('Combined Inbox'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync all',
onPressed: () {
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
),
],
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text(
'sharedinbox.de',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
ListTile(
leading: const Icon(Icons.manage_accounts),
title: const Text('Accounts'),
onTap: () {
Navigator.pop(context);
context.go('/accounts');
},
),
ListTile(
leading: const Icon(Icons.person_add),
title: const Text('Add account'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/add'));
},
),
const Divider(),
for (final account in accounts)
ListTile(
leading: const Icon(Icons.inbox),
title: Text(account.displayName),
subtitle: Text(account.email),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/${account.id}/mailboxes'));
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/preferences'));
},
),
ListTile(
leading: const Icon(Icons.history),
title: const Text('Undo Log'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/undo-log'));
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/about'));
},
),
],
),
);
}
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
final emailRepo = ref.watch(emailRepositoryProvider);
return RefreshIndicator(
onRefresh: () async {
final accounts = ref.read(allAccountsProvider).value ?? [];
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeAllInboxThreads(limit: _limit),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
if (threads.isEmpty) {
return ListView(
children: const [
SizedBox(
height: 300,
child: Center(child: Text('No emails')),
),
],
);
}
return _buildThreadList(threads, accountNames, showAccount);
},
),
);
}
Widget _buildThreadList(
List<EmailThread> threads,
Map<String, String> accountNames,
bool showAccount,
) {
final hasMore = threads.length == _limit;
return ListView.builder(
itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
},
);
}
Widget _buildThreadTile(
BuildContext ctx,
EmailThread t,
Map<String, String> accountNames,
bool showAccount,
) {
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
if (showAccount)
Text(
accountNames[t.accountId] ?? t.accountId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
child: tile,
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(t.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+9 -3
View File
@@ -194,7 +194,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Failed to open file: $e'),
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async {
if (_accountId == null) {
ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Select an account first'),
@@ -251,7 +255,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Send failed: $e'),
+3 -3
View File
@@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget {
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
+2 -3
View File
@@ -54,9 +54,8 @@ Future<Mailbox?> resolveMailboxByRole(
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m in mailboxes.where(
(m) => m.path != currentMailboxPath,
))
for (final m
in mailboxes.where((m) => m.path != currentMailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
+124 -100
View File
@@ -18,7 +18,6 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -73,7 +72,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null
? null
: () {
unawaited(_replyWithRecipientDialog(context, header, body));
unawaited(
_replyWithRecipientDialog(context, header, body),
);
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
@@ -93,22 +103,43 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
await ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
),
),
);
);
}
if (context.mounted) _navigateTo(context, header, nextEmailId);
},
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
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(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
@@ -123,15 +154,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
PopupMenuButton<String>(
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(
value: 'headers',
child: Text('Show Mail Headers'),
@@ -140,23 +166,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure',
child: Text('Show Mail Structure'),
),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'bug_report',
child: Text('Report a Bug'),
value: 'rfc',
child: Text('Show Raw Email'),
),
],
onSelected: (value) async {
if (value == 'forward' && header != 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') {
if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
@@ -166,10 +182,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_showStructure(context, body);
} else if (value == 'rfc') {
unawaited(_showRaw(context, header));
} else if (value == 'bug_report') {
unawaited(
context.push('/bug-report?emailId=${widget.emailId}'),
);
}
},
),
@@ -178,35 +190,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (d) {
final trusted =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
return _buildBody(context, d.$1, d.$2, trusted);
},
data: (d) => _buildBody(context, d.$1, d.$2),
),
);
}
Widget _buildBody(
BuildContext ctx,
Email? header,
EmailBody body,
List<String> trustedSenders,
) {
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final senderEmail = header?.from.isNotEmpty == true
? header!.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
if (hasHtml) ...[
if (!effectiveLoadImages)
if (!_loadRemoteImages)
Align(
alignment: Alignment.centerLeft,
child: Padding(
@@ -214,40 +210,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: OutlinedButton.icon(
icon: const Icon(Icons.image_outlined, size: 18),
label: const Text('Load remote images'),
onPressed: () {
setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
);
}
},
),
),
);
}
},
onPressed: () => setState(() => _loadRemoteImages = true),
),
),
),
SecureEmailWebView(
htmlBody: body.htmlBody!,
loadRemoteImages: effectiveLoadImages,
loadRemoteImages: _loadRemoteImages,
),
] else
SelectableText(
@@ -298,9 +267,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(widget.emailId),
);
final currentIndex =
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
@@ -555,7 +523,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(
context.push(
'/compose',
extra: {'prefillSubject': subject, 'prefillBody': quoted},
extra: {
'prefillSubject': subject,
'prefillBody': quoted,
},
),
);
}
@@ -657,9 +628,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.fetchRawRfc822(widget.emailId);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to fetch raw email: $e')),
);
return;
}
@@ -773,7 +744,47 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(
showDialog<void>(
context: context,
builder: (ctx) => EmailHeadersDialog(headers: body.headers),
builder: (ctx) => AlertDialog(
title: const Text('Mail Headers'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: body.headers.length,
itemBuilder: (ctx, i) {
final header = body.headers[i];
return Container(
color: i.isEven
? Theme.of(ctx).colorScheme.surfaceContainerHighest
: Theme.of(ctx).colorScheme.surface,
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
),
);
}
@@ -784,7 +795,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
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;
@@ -796,13 +809,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(
showDialog<void>(
context: context,
builder: (ctx) => Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: const Text('Mail Structure'),
leading: const CloseButton(),
),
body: ListView.builder(
builder: (ctx) => AlertDialog(
title: const Text('Mail Structure'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: rows.length,
itemBuilder: (ctx, i) {
final row = rows[i];
@@ -831,6 +843,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
),
);
@@ -888,8 +906,14 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
SegmentedButton<_Placement>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: _Placement.to, label: Text('To')),
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
ButtonSegment(
value: _Placement.to,
label: Text('To'),
),
ButtonSegment(
value: _Placement.cc,
label: Text('Cc'),
),
ButtonSegment(
value: _Placement.skip,
label: Text('Skip'),
+8 -3
View File
@@ -381,7 +381,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
return MaterialBanner(
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(
Icons.sync_problem,
color: Theme.of(context).colorScheme.error,
@@ -395,8 +399,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: const Text('Retry'),
),
TextButton(
onPressed: () =>
context.push('/accounts/${widget.accountId}/sync-log'),
onPressed: () => context.push(
'/accounts/${widget.accountId}/sync-log',
),
child: const Text('View log'),
),
TextButton(
+4 -6
View File
@@ -51,12 +51,10 @@ class MailboxListScreen extends ConsumerWidget {
? BottomAppBar(
child: Row(
children: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
],
),
+2 -3
View File
@@ -10,9 +10,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref,
) async {
final _searchHistoryProvider =
FutureProvider.autoDispose<List<String>>((ref) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
});
+3 -1
View File
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
title: Text(
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
+45 -71
View File
@@ -113,14 +113,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
@override
Widget build(BuildContext context) {
final trustedSenders =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
final senderEmail = widget.email.from.isNotEmpty
? widget.email.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
@@ -155,13 +147,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
],
),
),
if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
if (_expanded) _buildExpandedBody(),
],
),
);
}
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
Widget _buildExpandedBody() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
@@ -171,17 +163,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
FutureBuilder<EmailBody>(
future: _bodyFuture,
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) {
return const Center(
child: Padding(
@@ -192,48 +173,21 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
}
final body = snapshot.data!;
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasHtml) ...[
if (!effectiveLoadImages)
if (!_loadRemoteImages)
TextButton.icon(
icon: const Icon(Icons.image_outlined, size: 16),
label: const Text('Load remote images'),
onPressed: () {
setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
);
}
},
),
),
);
}
},
onPressed: () =>
setState(() => _loadRemoteImages = true),
),
SecureEmailWebView(
htmlBody: body.htmlBody!,
loadRemoteImages: effectiveLoadImages,
loadRemoteImages: _loadRemoteImages,
),
] else
SelectableText(
@@ -297,27 +251,47 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
}
Future<void> _delete() async {
final repo = ref.read(emailRepositoryProvider);
// Fetch data first for IMAP undo support
final original = await repo.getEmail(widget.email.id);
final destPath = await repo.deleteEmail(widget.email.id);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete email'),
content: const Text('Move this email to Trash?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
if (!mounted) return;
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
if (confirmed == true) {
final repo = ref.read(emailRepositoryProvider);
// Fetch data first for IMAP undo support
final original = await repo.getEmail(widget.email.id);
final destPath = await repo.deleteEmail(widget.email.id);
if (!mounted) return;
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
),
),
),
);
);
}
}
}
}
+3 -1
View File
@@ -84,7 +84,9 @@ class _UndoActionTile extends ConsumerWidget {
.read(undoServiceProvider.notifier)
.undo(actionId: action.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Action undone.'),
+9 -128
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
@@ -13,7 +12,6 @@ class UserPreferencesScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
@@ -92,7 +90,9 @@ class UserPreferencesScreen extends ConsumerWidget {
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text('Show the back button in the top bar.'),
subtitle: Text(
'Show the back button in the top bar.',
),
value: MenuPosition.top,
),
],
@@ -122,143 +122,24 @@ class UserPreferencesScreen extends ConsumerWidget {
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text('Show the next message in the mailbox.'),
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.'),
subtitle: Text(
'Return to the message list.',
),
value: AfterMailViewAction.showMailbox,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Offline email cache',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Pre-fetch email bodies in the background so they are available offline.',
),
),
RadioGroup<PrefetchMode>(
groupValue: prefs.prefetchMode,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updatePrefetchMode(value),
);
unawaited(registerBodyPrefetchTask(value));
},
child: const Column(
children: [
RadioListTile<PrefetchMode>(
title: Text('Wi-Fi only (default)'),
subtitle: Text(
'Pre-fetch bodies in the background when connected to Wi-Fi.',
),
value: PrefetchMode.wifiOnly,
),
RadioListTile<PrefetchMode>(
title: Text('Any network'),
subtitle: Text(
'Pre-fetch bodies on Wi-Fi and mobile data.',
),
value: PrefetchMode.always,
),
RadioListTile<PrefetchMode>(
title: Text('Disabled'),
subtitle: Text(
'Do not pre-fetch email bodies in the background.',
),
value: PrefetchMode.disabled,
),
],
),
),
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Text('Cache size limit:'),
const SizedBox(width: 16),
DropdownButton<int>(
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
items: const [
DropdownMenuItem(value: 50, child: Text('50 MB')),
DropdownMenuItem(value: 100, child: Text('100 MB')),
DropdownMenuItem(value: 200, child: Text('200 MB')),
DropdownMenuItem(value: 500, child: Text('500 MB')),
],
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateBodyCacheLimitMb(value),
);
},
),
],
),
),
const SizedBox(height: 8),
],
const Divider(),
ListTile(
title: Text(
'Trusted image senders',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Remote images are loaded automatically for these senders.',
),
),
...trustedSendersAsync.when(
loading: () => const [],
error: (_, __) => const [],
data: (senders) => senders.isEmpty
? [
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No trusted senders yet.'),
),
]
: [
for (final sender in senders)
ListTile(
title: Text(sender),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
),
],
),
],
),
),
);
}
int _nearestCacheOption(int mb) {
const options = [50, 100, 200, 500];
return options.reduce(
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
);
}
}
+2 -3
View File
@@ -26,9 +26,8 @@ String buildAboutMarkdown({
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 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'
-258
View File
@@ -1,258 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
/// Full-screen dialog for browsing email headers, organised into groups.
class EmailHeadersDialog extends StatelessWidget {
const EmailHeadersDialog({super.key, required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: const Text('Mail Headers'),
leading: const CloseButton(),
),
body: _HeadersBody(headers: headers),
),
);
}
}
class _HeadersBody extends StatelessWidget {
const _HeadersBody({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final receivedHeaders = <EmailHeader>[];
final listHeaders = <EmailHeader>[];
final arcHeaders = <EmailHeader>[];
final otherHeaders = <EmailHeader>[];
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
final xByPrefix = <String, List<EmailHeader>>{};
for (final h in headers) {
final lower = h.name.toLowerCase();
if (lower == 'received') {
receivedHeaders.add(h);
continue;
}
if (lower.startsWith('list-')) {
listHeaders.add(h);
continue;
}
if (lower.startsWith('arc-')) {
arcHeaders.add(h);
continue;
}
if (lower.startsWith('x-')) {
final parts = h.name.split('-');
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
xByPrefix.putIfAbsent(prefix, () => []).add(h);
continue;
}
otherHeaders.add(h);
}
final sections = <Widget>[];
if (otherHeaders.isNotEmpty) {
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
}
if (listHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'List- Headers', headers: listHeaders),
);
}
if (receivedHeaders.isNotEmpty) {
sections.add(_ReceivedSection(headers: receivedHeaders));
}
if (arcHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
);
}
// X- headers at bottom, each prefix in its own collapsible group.
final sortedPrefixes = xByPrefix.keys.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
for (final prefix in sortedPrefixes) {
sections.add(
_HeadersSection(
title: '$prefix Headers',
headers: xByPrefix[prefix]!,
),
);
}
return ListView(children: sections);
}
}
class _HeadersSection extends StatelessWidget {
const _HeadersSection({required this.title, required this.headers});
final String title;
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return ExpansionTile(
title: Text('$title (${headers.length})'),
children: [
for (var i = 0; i < headers.length; i++)
_HeaderRow(header: headers[i], index: i),
],
);
}
}
/// Received headers section — collapsed by default; shows inter-hop delays.
class _ReceivedSection extends StatelessWidget {
const _ReceivedSection({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final entries = _buildEntries(headers);
return ExpansionTile(
title: Text('Received (${headers.length})'),
children: [
for (var i = 0; i < entries.length; i++) ...[
_HeaderRow(header: entries[i].header, index: i),
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
],
],
);
}
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
final timestamps =
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
return [
for (var i = 0; i < headers.length; i++)
_ReceivedEntry(
header: headers[i],
delay: _computeDelay(timestamps, i),
),
];
}
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
if (i >= timestamps.length - 1) return null;
final current = timestamps[i];
final next = timestamps[i + 1];
if (current == null || next == null) return null;
final d = current.difference(next);
return d.isNegative ? Duration.zero : d;
}
}
class _ReceivedEntry {
const _ReceivedEntry({required this.header, this.delay});
final EmailHeader header;
final Duration? delay;
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.header, required this.index});
final EmailHeader header;
final int index;
@override
Widget build(BuildContext context) {
final bg = index.isEven
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
}
}
class _DelayRow extends StatelessWidget {
const _DelayRow({required this.delay});
final Duration delay;
@override
Widget build(BuildContext context) {
final color = _delayColor(delay);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Icon(Icons.arrow_downward, size: 14, color: color),
const SizedBox(width: 4),
Text(
_formatDuration(delay),
style: TextStyle(
fontSize: 12,
color: color,
fontWeight:
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
),
),
],
),
);
}
}
/// Parses the RFC 2822 timestamp from a Received header value.
///
/// Received headers end with `; date`, e.g.:
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
DateTime? _parseReceivedTimestamp(String value) {
final semiIndex = value.lastIndexOf(';');
if (semiIndex < 0) return null;
var s = value.substring(semiIndex + 1).trim();
// Strip parenthesised comments like (UTC).
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
// Strip leading day-of-week abbreviation like "Mon, ".
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
// Collapse runs of whitespace.
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
for (final fmt in [
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
]) {
try {
return fmt.parse(s);
} catch (_) {}
}
return null;
}
String _formatDuration(Duration d) {
if (d.inSeconds < 60) return '${d.inSeconds}s';
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
}
Color _delayColor(Duration d) {
if (d.inSeconds < 30) return Colors.green;
if (d.inSeconds < 300) return Colors.orange;
return Colors.red;
}
+11 -17
View File
@@ -111,16 +111,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
);
Future<void> _measureHeight(String _) async {
try {
final result = await _controller!.runJavaScriptReturningResult(
'document.documentElement.scrollHeight',
);
final h = double.tryParse(result.toString());
if (h != null && h > 0 && mounted) {
setState(() => _height = h);
}
} catch (_) {
// WebView not ready yet; height stays at default
final result = await _controller!.runJavaScriptReturningResult(
'document.documentElement.scrollHeight',
);
final h = double.tryParse(result.toString());
if (h != null && h > 0 && mounted) {
setState(() => _height = h);
}
}
@@ -191,14 +187,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
);
if (confirmed == true && mounted) {
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
final launched =
await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open: $url')),
);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

+9 -9
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: "direct main"
description:
@@ -1021,7 +1021,7 @@ packages:
source: hosted
version: "0.44.4"
stack_trace:
dependency: "direct main"
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
version: "0.6.16"
timezone:
dependency: transitive
description:
+1 -4
View File
@@ -33,7 +33,7 @@ dependencies:
flutter_secure_storage: ^10.0.0
# Date formatting
intl: ^0.20.2
intl: any
# File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4
@@ -58,9 +58,6 @@ dependencies:
flutter_local_notifications: ^21.0.0
workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
stack_trace: ^1.12.1
# App version metadata for crash reports
package_info_plus: ^10.1.0
share_plus: ^13.1.0
+1 -24
View File
@@ -5,35 +5,12 @@
],
"labels": ["dependencies"],
"github-actions": {
"enabled": false
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
},
{
"matchManagers": ["gomod"],
"matchFileNames": ["ci/**"],
"enabled": false
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^\\.forgejo/Dockerfile$"],
"matchStrings": ["DAGGER_VERSION=(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "dagger/dagger",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"fileMatch": ["^DAGGER\\.md$"],
"matchStrings": ["github:dagger/nix/v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)#dagger"],
"depNameTemplate": "dagger/dagger",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
}
]
}
+1054
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Verify that every container image referenced in ci/main.go is reachable.
# Runs skopeo inspect (manifest-only, no layer pull) for each From("...") call.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
FILE="$ROOT/ci/main.go"
images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
if [ -z "$images" ]; then
echo "check-ci-images: no From() image references found in $FILE"
exit 0
fi
fail=0
while IFS= read -r image; do
printf "check-ci-images: %-55s" "$image"
if skopeo inspect --no-creds "docker://$image" > /dev/null 2>&1; then
echo "OK"
else
echo "NOT FOUND"
fail=1
fi
done <<< "$images"
if [ "$fail" -eq 1 ]; then
echo ""
echo "ERROR: one or more container images in ci/main.go could not be resolved."
echo "Fix the image tag before committing."
exit 1
fi
-3
View File
@@ -41,9 +41,7 @@ const _excluded = {
'lib/ui/screens/account_send_screen.dart',
'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/address_emails_screen.dart',
'lib/ui/screens/bug_report_screen.dart',
'lib/ui/screens/changelog_screen.dart',
'lib/ui/screens/combined_inbox_screen.dart',
'lib/ui/screens/compose_screen.dart',
'lib/ui/screens/crash_screen.dart',
'lib/ui/screens/edit_account_screen.dart',
@@ -64,7 +62,6 @@ const _excluded = {
'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_headers_dialog.dart',
'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart',
+89 -66
View File
@@ -1,79 +1,102 @@
#!/usr/bin/env bash
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
set -euo pipefail
if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set."
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
echo "Error: DAGGER_STUNNEL_URL must be set."
exit 1
fi
echo "Decrypting secrets with SOPS..."
export SOPS_AGE_KEY="$SOPS_AGE_KEY"
SECRETS_JSON=$(mktemp)
trap "rm -f $SECRETS_JSON" EXIT
# Parse host and port (e.g., example.com:8774 or just example.com)
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
if [ "$host" == "$port" ]; then
port="8774"
fi
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
# Export all CI secrets to the GitHub Actions environment so subsequent steps
# can use them without referencing Forgejo secrets directly.
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.
# Avoid adding a second trailing newline for values that already end with one
# (e.g. SSH private keys), which can corrupt PEM parsing.
{
printf '%s<<__EOF__\n' "$name"
printf '%s' "$value"
[ "${value%$'\n'}" = "$value" ] && printf '\n'
printf '__EOF__\n'
} >> "$GITHUB_ENV"
MAX_PROBE_ATTEMPTS=5
PROBE_DELAY=30
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active server on $host:$port"
break
fi
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"
export_secret "SSH_KNOWN_HOSTS"
export_secret "SSH_USER"
export_secret "SSH_HOST"
export_secret "WEBSITE_SSH_HOST"
export_secret "PLAY_STORE_CONFIG_JSON"
export_secret "ANDROID_KEYSTORE_BASE64"
export_secret "ANDROID_KEYSTORE_PASSWORD"
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
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"
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
timeout 8 dagger version >/dev/null 2>&1; then
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
echo "Dagger configured at tcp://$host:$port (plain TCP)"
fi
exit 0
fi
echo "Plain TCP connection not available; trying TLS stunnel..."
# Verify the connection
echo "Verifying connection to Dagger engine via SSH tunnel..."
# Use a simple command that doesn't require complex GraphQL operations.
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
# Debug
ps aux | grep ssh
# 2b. Setup TLS credentials (passed as env vars from secrets)
mkdir -p /tmp/dagger-tls
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
chmod 600 /tmp/dagger-tls/client.key
# 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
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
+938
View File
@@ -0,0 +1,938 @@
#!/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 ci.yml push-to-main runs."""
def _ci_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
"status": status, "id": run_id}
def _deploy_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
"status": status, "id": run_id}
def test_skips_deploy_run_returns_ci_run(self):
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
# must be excluded by workflow_id filter.
runs = [self._deploy_run(1), self._ci_run(2)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_returns_none_when_only_deploy_runs_exist(self):
runs = [self._deploy_run(1)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_none_when_only_schedule_runs_exist(self):
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
"status": "success", "id": 1}]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_ci_push_to_main_run(self):
runs = [self._ci_run(42, status="running")]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 42)
class TestLatestCiRunForBranch(unittest.TestCase):
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
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 TestCatchupSkipsQuestionIssues(unittest.TestCase):
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
return {"number": pr_number, "head": {"ref": branch}}
def test_skips_merge_when_issue_has_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_not_called()
mock_comment.assert_not_called()
mock_labels.assert_not_called()
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._close_issue"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(50)
class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command."""
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
return {
"pid": 999999999,
"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 test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
written_state = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written_state["pid"] = pid
written_state["issue"] = issue
written_state["kind"] = kind
written_state["session_name"] = session_name
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
patch("agent_loop._start_agent", return_value=77) as mock_start, \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_start.assert_called_once()
prompt = mock_start.call_args[0][0]
self.assertIn("Rebase branch", prompt)
self.assertIn("issue-10-fix", prompt)
self.assertEqual(written_state.get("kind"), "pending-ci")
self.assertEqual(written_state.get("issue"), 10)
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
"""mergeable=true, second attempt succeeds → issue 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(), self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
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_called_once_with(10)
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
"""All retries exhausted with PR still open → falls through to State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(),
self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
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()
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()
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""Tests for verify_playstore_deploy.py."""
import os
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent))
import verify_playstore_deploy
def _make_session(version_code, track="internal"):
"""Return a mock AuthorizedSession with the given version code on the track."""
session = MagicMock()
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": "edit-99"}
session.post.return_value = edit_resp
track_resp = MagicMock()
track_resp.json.return_value = {
"releases": [{"versionCodes": [str(version_code)], "status": "completed"}]
}
session.get.return_value = track_resp
session.delete.return_value = MagicMock()
return session
class TestMissingEnv(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
verify_playstore_deploy.main()
self.assertEqual(ctx.exception.code, 1)
class TestRecentDeploy(unittest.TestCase):
def _run(self, version_code):
session = _make_session(version_code)
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
verify_playstore_deploy.main()
def test_recent_version_code_passes(self):
# Version code is Unix timestamp — a very recent one should pass.
recent_vc = int(time.time()) - 60 # 1 minute ago
self._run(recent_vc)
def test_old_version_code_fails(self):
old_vc = int(time.time()) - 7200 # 2 hours ago
with self.assertRaises(SystemExit) as ctx:
self._run(old_vc)
self.assertEqual(ctx.exception.code, 1)
class TestEmptyTrack(unittest.TestCase):
def _run_empty(self, releases):
session = MagicMock()
session.post.return_value = MagicMock(**{"json.return_value": {"id": "edit-1"}})
session.get.return_value = MagicMock(**{"json.return_value": {"releases": releases}})
session.delete.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
verify_playstore_deploy.main()
def test_no_releases_exits(self):
with self.assertRaises(SystemExit) as ctx:
self._run_empty([])
self.assertEqual(ctx.exception.code, 1)
def test_release_with_no_version_codes_exits(self):
with self.assertRaises(SystemExit) as ctx:
self._run_empty([{"status": "completed", "versionCodes": []}])
self.assertEqual(ctx.exception.code, 1)
if __name__ == "__main__":
unittest.main()
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""Verify that the Android app was recently published to the Play Store internal track.
The publish-android pipeline sets versionCode = int(time.Now().Unix()), so a
freshly deployed release always has a version code close to the current Unix
timestamp. This script queries the internal track and fails if the latest
version code is older than _MAX_DEPLOY_AGE_SECONDS, which would mean the
deployment silently did not land.
"""
import json
import os
import sys
import time
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
TRACK = "internal"
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
# Allow up to one hour for the build + upload to complete.
_MAX_DEPLOY_AGE_SECONDS = 3600
def main():
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
if not config_json:
print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr)
sys.exit(1)
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
# Open a read-only edit to query the current track state.
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
try:
track_resp = session.get(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
timeout=30,
)
track_resp.raise_for_status()
track_data = track_resp.json()
finally:
# Discard the edit — we made no changes.
try:
session.delete(f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}", timeout=30)
except Exception:
pass
releases = track_data.get("releases", [])
if not releases:
print(
f"ERROR: No releases found on {TRACK} track — deploy may have failed silently",
file=sys.stderr,
)
sys.exit(1)
all_version_codes = [
int(vc)
for release in releases
for vc in release.get("versionCodes", [])
]
if not all_version_codes:
print("ERROR: Latest release has no version codes", file=sys.stderr)
sys.exit(1)
latest_vc = max(all_version_codes)
now = int(time.time())
# versionCode is set to Unix timestamp by PublishAndroid in ci/main.go.
age_seconds = now - latest_vc
print(f"Latest version code on {TRACK} track: {latest_vc}")
print(f"Current time: {now} — version code age: {age_seconds}s")
if age_seconds > _MAX_DEPLOY_AGE_SECONDS:
print(
f"::error::Latest version code {latest_vc} is {age_seconds}s old "
f"(limit: {_MAX_DEPLOY_AGE_SECONDS}s). The deploy may have failed silently.",
file=sys.stderr,
)
sys.exit(1)
print(f"OK: version {latest_vc} verified on {TRACK} track ({age_seconds}s old)")
if __name__ == "__main__":
main()
-33
View File
File diff suppressed because one or more lines are too long
-3
View File
@@ -1,3 +0,0 @@
module sharedinbox.de/bugreport
go 1.21
-282
View File
@@ -1,282 +0,0 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// BugReport represents the data stored in report.json
type BugReport struct {
Description string `json:"description"`
Email string `json:"email"`
AboutInfo string `json:"about_info"`
EmailData string `json:"email_data,omitempty"`
SyncLog string `json:"sync_log,omitempty"`
Timestamp time.Time `json:"timestamp"`
HashedIP string `json:"hashed_ip"`
}
var (
rateLimitMu sync.Mutex
requestTimes []time.Time
)
// checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally.
func checkRateLimit() (bool, time.Duration) {
rateLimitMu.Lock()
defer rateLimitMu.Unlock()
now := time.Now()
// Clean up timestamps older than 1 minute
var valid []time.Time
for _, t := range requestTimes {
if now.Sub(t) < time.Minute {
valid = append(valid, t)
}
}
requestTimes = valid
if len(requestTimes) >= 10 {
// Calculate time until the oldest request in the window falls out of it
oldest := requestTimes[0]
remaining := time.Minute - now.Sub(oldest)
if remaining < 0 {
remaining = 0
}
return false, remaining
}
requestTimes = append(requestTimes, now)
return true, 0
}
func generateUUID() (string, error) {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
return "", err
}
// Format as UUID v4 structure
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
}
func hashIP(ip string) string {
h := sha256.New()
h.Write([]byte(ip))
return hex.EncodeToString(h.Sum(nil))
}
func bugReportHandler(storageDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Enable CORS so the web app (if applicable) can upload
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting check
allowed, waitTime := checkRateLimit()
if !allowed {
retryAfter := int(waitTime.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."})
return
}
// Limit body size to 20 MB (20 * 1024 * 1024 bytes)
const maxBodySize = 20 * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
// Parse the multipart form
err := r.ParseMultipartForm(maxBodySize)
if err != nil {
log.Printf("Failed to parse multipart form: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."})
return
}
defer func() {
_ = r.MultipartForm.RemoveAll()
}()
description := r.FormValue("description")
aboutInfo := r.FormValue("about_info")
if description == "" || aboutInfo == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."})
return
}
email := r.FormValue("email")
emailData := r.FormValue("email_data")
syncLog := r.FormValue("sync_log")
// Get IP address
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
// Check X-Forwarded-For if behind a proxy
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if len(parts) > 0 {
ip = strings.TrimSpace(parts[0])
}
}
hashedIP := hashIP(ip)
uuidVal, err := generateUUID()
if err != nil {
log.Printf("Failed to generate UUID: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
now := time.Now()
timestampStr := now.Format("20060102_150405")
dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal)
reportDir := filepath.Join(storageDir, dirName)
err = os.MkdirAll(reportDir, 0750)
if err != nil {
log.Printf("Failed to create report directory: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write report.json
report := BugReport{
Description: description,
Email: email,
AboutInfo: aboutInfo,
EmailData: emailData,
SyncLog: syncLog,
Timestamp: now,
HashedIP: hashedIP,
}
reportJSONPath := filepath.Join(reportDir, "report.json")
reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Printf("Failed to create report.json: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer reportJSONFile.Close()
enc := json.NewEncoder(reportJSONFile)
enc.SetIndent("", " ")
err = enc.Encode(report)
if err != nil {
log.Printf("Failed to write report.json: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Save attachments
form := r.MultipartForm
files := form.File["attachments[]"]
for i, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
log.Printf("Failed to open attachment %d: %v", i, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer file.Close()
// Sanitize filename to avoid directory traversal
baseName := filepath.Base(fileHeader.Filename)
attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName)
attachmentPath := filepath.Join(reportDir, attachmentName)
destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Printf("Failed to create attachment file %s: %v", attachmentName, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer destFile.Close()
_, err = io.Copy(destFile, file)
if err != nil {
log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal})
}
}
func main() {
port := os.Getenv("BUGREPORT_PORT")
if port == "" {
port = "8090"
}
storageDir := os.Getenv("BUGREPORT_STORAGE_DIR")
if storageDir == "" {
storageDir = "./reports"
}
// Create storage directory if it doesn't exist
err := os.MkdirAll(storageDir, 0750)
if err != nil {
log.Fatalf("Failed to create storage directory %s: %v", storageDir, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir))
addr := net.JoinHostPort("127.0.0.1", port)
log.Printf("Bug report server starting on %s...", addr)
log.Printf("Reports storage directory: %s", storageDir)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
+49 -53
View File
@@ -20,67 +20,63 @@ Future<imap.ImapClient> _fakeImapConnect(
throw const SocketException('fake — no real IMAP server in tests');
void main() {
test(
'AccountSyncManager schedules IMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
final logs = _FakeLogs();
test('AccountSyncManager schedules IMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager(
accounts,
mailboxes,
emails,
syncLog: logs,
imapConnect: _fakeImapConnect,
);
final manager = AccountSyncManager(
accounts,
mailboxes,
emails,
syncLog: logs,
imapConnect: _fakeImapConnect,
);
final a1 = _account('1');
final a2 = _account('2');
final a1 = _account('1');
final a2 = _account('2');
manager.start();
accounts.push([a1, a2]);
manager.start();
accounts.push([a1, a2]);
// Allow some time for listeners to fire.
await Future<void>.delayed(const Duration(milliseconds: 100));
// Allow some time for listeners to fire.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose();
},
);
manager.dispose();
});
test(
'AccountSyncManager schedules JMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
final logs = _FakeLogs();
test('AccountSyncManager schedules JMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager(
accounts,
mailboxes,
emails,
syncLog: logs,
);
final manager = AccountSyncManager(
accounts,
mailboxes,
emails,
syncLog: logs,
);
final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2');
final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2');
manager.start();
accounts.push([a1, a2]);
manager.start();
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['2'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose();
},
);
manager.dispose();
});
}
Account _account(String id) => Account(
@@ -175,7 +171,11 @@ class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{};
@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([]);
@override
@@ -186,10 +186,6 @@ class _FakeEmails implements EmailRepository {
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]);
+49 -51
View File
@@ -566,61 +566,59 @@ void main() {
expect(pending.first.changeType, 'delete');
});
test(
'downloadAttachment fetches binary attachment bytes from IMAP',
() async {
final attachmentBytes = Uint8List.fromList(
List.generate(32, (i) => i + 1),
test('downloadAttachment fetches binary attachment bytes from IMAP',
() async {
final attachmentBytes = Uint8List.fromList(
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';
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,
await client.appendMessage(
builder.buildMimeMessage(),
targetMailboxPath: 'INBOX',
);
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,
);
await client.appendMessage(
builder.buildMimeMessage(),
targetMailboxPath: 'INBOX',
);
} finally {
await client.logout();
}
} finally {
await client.logout();
}
final r = makeRepo();
await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX');
final r = makeRepo();
await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first;
expect(emails, hasLength(1));
expect(emails.first.hasAttachment, isTrue);
final emails = await r.emails.observeEmails('test', 'INBOX').first;
expect(emails, hasLength(1));
expect(emails.first.hasAttachment, isTrue);
final body = await r.emails.getEmailBody(emails.first.id);
expect(body.attachments, hasLength(1));
expect(body.attachments.first.filename, attachmentName);
expect(body.attachments.first.contentType, attachmentMime);
expect(body.attachments.first.fetchPartId, isNotEmpty);
final body = await r.emails.getEmailBody(emails.first.id);
expect(body.attachments, hasLength(1));
expect(body.attachments.first.filename, attachmentName);
expect(body.attachments.first.contentType, attachmentMime);
expect(body.attachments.first.fetchPartId, isNotEmpty);
final path = await r.emails.downloadAttachment(
emails.first.id,
body.attachments.first,
);
final downloaded = await File(path).readAsBytes();
expect(downloaded, equals(attachmentBytes));
},
);
final path = await r.emails.downloadAttachment(
emails.first.id,
body.attachments.first,
);
final downloaded = await File(path).readAsBytes();
expect(downloaded, equals(attachmentBytes));
});
}
-43
View File
@@ -1,43 +0,0 @@
// Loads Material fonts (Roboto + MaterialIcons) before any test runs so that
// golden/screenshot tests render real text instead of placeholder boxes.
//
// Flutter widget tests don't load fonts by default. This file is discovered
// automatically by `flutter test` for every test under test/.
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
setUpAll(_loadMaterialFonts);
await testMain();
}
Future<void> _loadMaterialFonts() async {
// Locate Flutter's cached material fonts relative to the flutter_tester executable.
// Layout: <flutter-root>/bin/cache/artifacts/engine/linux-x64/flutter_tester
// <flutter-root>/bin/cache/artifacts/material_fonts/
final cacheDir =
File(Platform.resolvedExecutable).parent.parent.parent.parent;
final fontsDir = '${cacheDir.path}/artifacts/material_fonts';
Future<ByteData> load(String name) async {
final bytes = await File('$fontsDir/$name').readAsBytes();
return ByteData.view(bytes.buffer);
}
await (FontLoader('Roboto')
..addFont(load('Roboto-Regular.ttf'))
..addFont(load('Roboto-Medium.ttf'))
..addFont(load('Roboto-Bold.ttf'))
..addFont(load('Roboto-Italic.ttf'))
..addFont(load('Roboto-MediumItalic.ttf'))
..addFont(load('Roboto-BoldItalic.ttf')))
.load();
await (FontLoader('MaterialIcons')
..addFont(load('MaterialIcons-Regular.otf')))
.load();
}
-427
View File
@@ -1,427 +0,0 @@
// Generates Play Store promotional screenshots for all three device classes.
//
// Run with:
// fvm flutter test test/screenshot_automation_test.dart --update-goldens
//
// Output: screenshots/{phone,tablet_7in,tablet_10in}/{light,dark}/<scene>.png
// at the repository root (one directory above test/).
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'widget/helpers.dart';
// ---------------------------------------------------------------------------
// Device configurations
// ---------------------------------------------------------------------------
typedef _Device = ({String name, double width, double height, double dpr});
const _devices = <_Device>[
(name: 'phone', width: 1080.0, height: 1920.0, dpr: 3.0),
(name: 'tablet_7in', width: 1200.0, height: 1920.0, dpr: 2.0),
(name: 'tablet_10in', width: 1600.0, height: 2560.0, dpr: 2.0),
];
// ---------------------------------------------------------------------------
// Sample data — fixed date so golden files are stable between runs
// ---------------------------------------------------------------------------
const _kAccount = Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@sharedinbox.de',
imapHost: 'imap.sharedinbox.de',
smtpHost: 'smtp.sharedinbox.de',
);
final _kDate = DateTime(2025, 5, 14, 10, 30);
Email _email({
required String id,
required String subject,
required String fromName,
required String fromEmail,
bool isSeen = true,
bool isFlagged = false,
bool hasAttachment = false,
String? preview,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: [EmailAddress(name: fromName, email: fromEmail)],
to: const [EmailAddress(name: 'Alice', email: 'alice@sharedinbox.de')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
preview: preview,
);
final _sampleEmails = [
_email(
id: 'acc-1:1',
subject: 'Re: Project kick-off next week',
fromName: 'Maria Hoffmann',
fromEmail: 'maria@corp.example',
isSeen: false,
preview: 'Sounds great! I will prepare the slides beforehand.',
),
_email(
id: 'acc-1:2',
subject: 'Your invoice #2024-0312 is ready',
fromName: 'Billing',
fromEmail: 'billing@service.example',
isSeen: false,
preview: 'Your invoice for May is attached as a PDF.',
),
_email(
id: 'acc-1:3',
subject: 'Team lunch — Friday 12:30',
fromName: 'Thomas Müller',
fromEmail: 'thomas@corp.example',
isFlagged: true,
preview: 'The Italian place on Main Street. RSVP by Thursday please.',
),
_email(
id: 'acc-1:4',
subject: 'Quarterly review agenda',
fromName: 'HR Team',
fromEmail: 'hr@corp.example',
preview:
"Please find the agenda for next week's quarterly review attached.",
),
_email(
id: 'acc-1:5',
subject: 'Weekend hiking trip — photos inside',
fromName: 'Jonas Weber',
fromEmail: 'jonas@personal.example',
hasAttachment: true,
preview: 'Had such a great time! Here are the photos from Saturday.',
),
_email(
id: 'acc-1:6',
subject: 'Reminder: dentist appointment tomorrow',
fromName: 'City Dental',
fromEmail: 'noreply@citydental.example',
preview: 'Your appointment is confirmed for Thursday at 14:00.',
),
_email(
id: 'acc-1:7',
subject: 'Re: Feedback on the draft',
fromName: 'Laura Schmidt',
fromEmail: 'laura@corp.example',
isSeen: false,
preview: 'I left some comments on page 3. Overall it looks really solid!',
),
_email(
id: 'acc-1:8',
subject: 'Flight confirmation PNR XYZ123',
fromName: 'Sunshine Airlines',
fromEmail: 'noreply@airline.example',
preview:
'Your booking is confirmed. Check-in opens 24 hours before departure.',
),
];
final _sampleMailboxes = [
const Mailbox(
id: 'acc-1:INBOX',
accountId: 'acc-1',
path: 'INBOX',
name: 'INBOX',
role: 'inbox',
unreadCount: 3,
totalCount: 8,
),
const Mailbox(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
role: 'sent',
unreadCount: 0,
totalCount: 42,
),
const Mailbox(
id: 'acc-1:Drafts',
accountId: 'acc-1',
path: 'Drafts',
name: 'Drafts',
role: 'drafts',
unreadCount: 0,
totalCount: 1,
),
const Mailbox(
id: 'acc-1:Trash',
accountId: 'acc-1',
path: 'Trash',
name: 'Trash',
role: 'trash',
unreadCount: 0,
totalCount: 7,
),
];
// Email shown in the detail scene.
final _detailEmail = _email(
id: 'acc-1:1',
subject: 'Re: Project kick-off next week',
fromName: 'Maria Hoffmann',
fromEmail: 'maria@corp.example',
);
const _detailBody = EmailBody(
emailId: 'acc-1:1',
attachments: [],
textBody: 'Hi Alice,\n\n'
'Sounds great! I will prepare the slides beforehand so we have '
'something concrete to discuss.\n\n'
'Looking forward to meeting everyone!\n\n'
'Best,\nMaria',
);
// Emails shown when the user searches for "invoice".
final _searchResults = [
_email(
id: 'acc-1:2',
subject: 'Your invoice #2024-0312 is ready',
fromName: 'Billing',
fromEmail: 'billing@service.example',
isSeen: false,
),
_email(
id: 'acc-1:9',
subject: 'Invoice for March services',
fromName: 'Cloud Services',
fromEmail: 'noreply@cloud.example',
),
];
// ---------------------------------------------------------------------------
// Provider override sets for each scene
// ---------------------------------------------------------------------------
List<Override> _inboxOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: _sampleEmails),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _detailOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: _sampleEmails,
emailDetail: _detailEmail,
emailBody: _detailBody,
),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _composeOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: _sampleEmails),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _mailboxOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _searchOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: _sampleEmails,
searchResults: _searchResults,
),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
// ---------------------------------------------------------------------------
// Tests — 3 devices × 2 themes × 5 scenes = 30 golden files
// ---------------------------------------------------------------------------
void main() {
for (final device in _devices) {
for (final themeMode in [ThemeMode.light, ThemeMode.dark]) {
final themeName = themeMode == ThemeMode.light ? 'light' : 'dark';
// Golden files are stored relative to this test file (test/).
// The ../ prefix places them at repo root under screenshots/.
final dir = '../screenshots/${device.name}/$themeName';
final prefix = '${device.name}_$themeName';
group('${device.name}/$themeName', () {
void setDevice(WidgetTester tester) {
tester.view.physicalSize = Size(device.width, device.height);
tester.view.devicePixelRatio = device.dpr;
addTearDown(tester.view.reset);
}
testWidgets('inbox_list', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _inboxOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_inbox_list.png'),
);
});
testWidgets('email_detail', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
// The colon in "acc-1:1" must be percent-encoded in the URL.
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A1',
overrides: _detailOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_email_detail.png'),
);
});
testWidgets('compose', (tester) async {
setDevice(tester);
// Start at the inbox, then navigate to compose with pre-fill extras
// so GoRouter can pass them to ComposeScreen via state.extra.
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _composeOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
GoRouter.of(tester.element(find.byType(EmailListScreen))).go(
'/compose',
extra: <String, dynamic>{
'accountId': 'acc-1',
'prefillTo': 'thomas@corp.example',
'prefillSubject': 'Re: Team lunch — Friday 12:30',
'prefillBody':
'Hi Thomas,\n\nCount me in! See you on Friday.\n\nBest,\nAlice',
},
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_compose.png'),
);
});
testWidgets('mailbox_list', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes',
overrides: _mailboxOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_mailbox_list.png'),
);
});
testWidgets('search_results', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _searchOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'invoice');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_search_results.png'),
);
});
});
}
}
}
@@ -73,15 +73,13 @@ abstract class AccountRepositoryContract {
expect(await repo.getPassword(_a.id), 'new');
});
test(
'removeAccount makes account disappear from observeAccounts',
() async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
await repo.removeAccount(_a.id);
expect(await repo.observeAccounts().first, isEmpty);
},
);
test('removeAccount makes account disappear from observeAccounts',
() async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
await repo.removeAccount(_a.id);
expect(await repo.observeAccounts().first, isEmpty);
});
test('getAccount returns null after removeAccount', () async {
final repo = makeRepo();
+27 -27
View File
@@ -37,41 +37,44 @@ void main() {
// MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff.
test(
'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async {
final syncLog = FakeSyncLogRepository();
'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async {
final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
syncLog: syncLog,
);
final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
syncLog: syncLog,
);
m.start();
m.start();
// Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100));
// Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse);
expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100));
// Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1));
// Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1));
m.dispose();
},
);
m.dispose();
});
}
class FakeEmailRepository implements EmailRepository {
@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([]);
@override
Stream<List<EmailThread>> observeThreads(
@@ -81,9 +84,6 @@ class FakeEmailRepository implements EmailRepository {
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]);
@override
@@ -287,17 +287,6 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>);
@override
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
(super.noSuchMethod(
Invocation.method(
#observeAllInboxThreads,
[],
{#limit: limit},
),
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>);
@override
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
String? accountId,
+8 -9
View File
@@ -9,13 +9,12 @@ void main() {
// startup, throwing PlatformException(channel-error, ...).
// registerBackgroundSync() must absorb the failure and let the app continue.
test(
'registerBackgroundSync completes without throwing when plugin is unavailable',
() async {
// In the unit-test environment the native WorkManager plugin is not
// registered, so Workmanager().initialize() throws a PlatformException or
// MissingPluginException. The fix catches it. This test fails before the
// fix (exception propagates) and passes after it (exception is swallowed).
await expectLater(registerBackgroundSync(), completes);
},
);
'registerBackgroundSync completes without throwing when plugin is unavailable',
() async {
// In the unit-test environment the native WorkManager plugin is not
// registered, so Workmanager().initialize() throws a PlatformException or
// MissingPluginException. The fix catches it. This test fails before the
// fix (exception propagates) and passes after it (exception is swallowed).
await expectLater(registerBackgroundSync(), completes);
});
}
+2 -3
View File
@@ -86,9 +86,8 @@ void main() {
final result = injectInlineImages(html, msg);
// Extract base64 payload from the data URI.
final match = RegExp(
r'data:image/png;base64,([A-Za-z0-9+/=]+)',
).firstMatch(result);
final match =
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
expect(match, isNotNull);
final decoded = base64.decode(match!.group(1)!);
expect(decoded.length, greaterThan(0));
+17 -4
View File
@@ -44,7 +44,10 @@ abstract class EmailRepositoryContract {
void run() {
test('observeEmails starts empty', () async {
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 {
@@ -58,7 +61,10 @@ abstract class EmailRepositoryContract {
test('observeEmails only returns emails for the given mailbox', () async {
final repo = await makeRepo();
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 {
@@ -110,7 +116,11 @@ abstract class EmailRepositoryContract {
test('setFlag flagged updates isFlagged', () async {
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);
final email = await repo.getEmail('er-acc:11');
expect(email!.isFlagged, isTrue);
@@ -147,7 +157,10 @@ abstract class EmailRepositoryContract {
test('observeThreads starts empty', () async {
final repo = await makeRepo();
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
expect(
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
});
}
}
+199 -260
View File
@@ -453,103 +453,47 @@ void main() {
expect(results.first.subject, 'foobar baz');
});
test(
'searchAddresses returns results sorted by most recently used',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
test('searchAddresses returns results sorted by most recently used',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
final older = DateTime(2024);
final newer = DateTime(2024, 6);
final older = DateTime(2024);
final newer = DateTime(2024, 6);
// Two emails — older one has alice@, newer one has bob@.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:old',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: older,
toAddresses: const Value(
'[{"name":"Alice","email":"alice@example.com"}]',
),
// Two emails — older one has alice@, newer one has bob@.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:old',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: older,
toAddresses: const Value(
'[{"name":"Alice","email":"alice@example.com"}]',
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:new',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
receivedAt: newer,
toAddresses: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:new',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
receivedAt: newer,
toAddresses: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
);
),
);
// Query matching both; newer (bob) should come first.
final results = await r.emails.searchAddresses(null, 'example');
expect(results.map((a) => a.email).toList(), [
'bob@example.com',
'alice@example.com',
]);
},
);
test(
'searchAddresses prioritises sent-folder addresses over newer received',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Register the Sent mailbox so searchAddresses knows its role.
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
role: const Value('sent'),
),
);
// Older sent email: user deliberately wrote to info@foo.de.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:sent-1',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 1,
receivedAt: DateTime(2025),
toAddresses: const Value(
'[{"name":"Foo","email":"info@foo.de"}]',
),
),
);
// Newer received email: spam arrived today from info@spam.de.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:inbox-1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
receivedAt: DateTime(2026),
fromJson: const Value(
'[{"name":"Spam","email":"info@spam.de"}]',
),
),
);
// Even though spam is newer, the sent-folder address should win.
final results = await r.emails.searchAddresses(null, 'info');
expect(results.map((a) => a.email).toList(), [
'info@foo.de',
'info@spam.de',
]);
},
);
// Query matching both; newer (bob) should come first.
final results = await r.emails.searchAddresses(null, 'example');
expect(
results.map((a) => a.email).toList(),
['bob@example.com', 'alice@example.com'],
);
});
// ── IMAP method tests ────────────────────────────────────────────────────
@@ -753,47 +697,47 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
});
test(
'snooze flush selects src mailbox and moves email to Snoozed',
() async {
final spy = SnoozeSpyImapClient();
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'Snoozed',
uid: 5,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'Email',
resourceId: 'acc-1:5',
changeType: 'snooze',
payload: jsonEncode({
'uid': 5,
'src': 'INBOX',
'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}),
createdAt: DateTime.now(),
),
);
test('snooze flush selects src mailbox and moves email to Snoozed',
() async {
final spy = SnoozeSpyImapClient();
final r = _makeRepos(
imapConnect: (_, __, ___) async => spy,
);
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'Snoozed',
uid: 5,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'Email',
resourceId: 'acc-1:5',
changeType: 'snooze',
payload: jsonEncode({
'uid': 5,
'src': 'INBOX',
'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}),
createdAt: DateTime.now(),
),
);
await r.emails.flushPendingChanges('acc-1', 'pw');
await r.emails.flushPendingChanges('acc-1', 'pw');
// Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Source mailbox extracted from 'src', not 'mailboxPath'.
expect(spy.selectedMailbox, 'INBOX');
expect(spy.createdMailbox, 'Snoozed');
expect(spy.movedToMailbox, 'Snoozed');
},
);
// Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Source mailbox extracted from 'src', not 'mailboxPath'.
expect(spy.selectedMailbox, 'INBOX');
expect(spy.createdMailbox, 'Snoozed');
expect(spy.movedToMailbox, 'Snoozed');
});
});
group('Snooze', () {
@@ -1696,123 +1640,119 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
});
test(
'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
() async {
final List<Map<String, dynamic>> capturedBodies = [];
final client = MockClient((req) async {
if (req.url.path.contains('well-known')) {
return http.Response(
jsonEncode({
'apiUrl': 'https://jmap.example.com/api/',
'accounts': {
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
},
'primaryAccounts': {
'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1',
},
'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
final body = jsonDecode(req.body) as Map<String, dynamic>;
capturedBodies.add(body);
final calls = body['methodCalls'] as List;
final methodName = (calls.first as List)[0] as String;
if (methodName == 'Mailbox/set') {
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0',
],
],
}),
200,
);
}
test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
() async {
final List<Map<String, dynamic>> capturedBodies = [];
final client = MockClient((req) async {
if (req.url.path.contains('well-known')) {
return http.Response(
jsonEncode({
'apiUrl': 'https://jmap.example.com/api/',
'accounts': {
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
},
'primaryAccounts': {
'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1',
},
'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
final body = jsonDecode(req.body) as Map<String, dynamic>;
capturedBodies.add(body);
final calls = body['methodCalls'] as List;
final methodName = (calls.first as List)[0] as String;
if (methodName == 'Mailbox/set') {
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Email/set',
{'accountId': 'acct1', 'updated': {}},
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0',
],
],
}),
200,
);
});
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',
}
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Email/set',
{'accountId': 'acct1', 'updated': {}},
'0',
],
],
}),
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.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
await r.emails.flushPendingChanges('jmap-1', 'pw');
// First API call should be Mailbox/set to create the Snoozed folder.
expect(capturedBodies, hasLength(2));
final firstCall =
((capturedBodies.first['methodCalls'] as List).first as List)[0];
expect(firstCall, 'Mailbox/set');
// Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Second call should be Email/set using the newly created mailbox ID.
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
as List)[1] as Map<String, dynamic>;
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
as Map<String, dynamic>;
expect(update['mailboxIds/mbx-snoozed'], true);
},
);
// First API call should be Mailbox/set to create the Snoozed folder.
expect(capturedBodies, hasLength(2));
final firstCall =
((capturedBodies.first['methodCalls'] as List).first as List)[0];
expect(firstCall, 'Mailbox/set');
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',
}),
);
// Second call should be Email/set using the newly created mailbox ID.
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
as List)[1] as Map<String, dynamic>;
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
as Map<String, dynamic>;
expect(update['mailboxIds/mbx-snoozed'], true);
});
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).
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
},
);
await r.emails.flushPendingChanges('jmap-1', 'pw');
// 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', () {
@@ -2342,42 +2282,41 @@ void main() {
group('concurrent moves', () {
test(
'two simultaneous moves enqueue two changes and leave email in last destination',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 5,
receivedAt: DateTime(2024),
),
);
'two simultaneous moves enqueue two changes and leave email in last destination',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 5,
receivedAt: DateTime(2024),
),
);
// Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]);
// Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move'));
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash']));
final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5');
expect(
email!.mailboxPath,
anyOf('Archive', 'Trash'),
reason:
'email must be optimistically moved to one of the two destinations',
);
},
);
final email = await r.emails.getEmail('acc-1:5');
expect(
email!.mailboxPath,
anyOf('Archive', 'Trash'),
reason:
'email must be optimistically moved to one of the two destinations',
);
});
});
group('IMAP SMTP auth failure', () {
@@ -61,7 +61,10 @@ abstract class MailboxRepositoryContract {
test('findMailboxByRole returns null when no match', () async {
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 {
+67 -69
View File
@@ -486,11 +486,8 @@ void main() {
);
await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes.createMailboxWithRole(
'jmap-1',
'Archive',
'archive',
);
final result = await r.mailboxes
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
@@ -501,80 +498,81 @@ void main() {
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'},
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',
'0',
],
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()),
);
});
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());
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');
// 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'),
),
);
// 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');
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);
},
);
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);
});
});
});
}
+80 -104
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 38);
expect(db.schemaVersion, 36);
await db.close();
});
@@ -178,17 +178,17 @@ void main() {
// v28: mime_tree_json column on email_bodies.
await db
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
.customSelect(
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.get();
// v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = await _tableColumns(
db,
'sync_log_mailboxes',
);
final syncLogMailboxColumns =
await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms'));
// v32: local_sieve_applied table.
@@ -209,22 +209,19 @@ void main() {
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test(
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async {
final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync();
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async {
final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
// Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
@@ -245,7 +242,7 @@ void main() {
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL,
@@ -257,7 +254,7 @@ void main() {
updated_at INTEGER NOT NULL
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE mailboxes (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
@@ -268,7 +265,7 @@ void main() {
role TEXT NULL
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
@@ -292,7 +289,7 @@ void main() {
snoozed_from_mailbox_path TEXT NULL
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE threads (
account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL,
@@ -309,7 +306,7 @@ void main() {
PRIMARY KEY (account_id, mailbox_path, id)
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
text_body TEXT NULL,
@@ -319,7 +316,7 @@ void main() {
headers_json TEXT NULL
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE sync_logs (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
@@ -336,7 +333,7 @@ void main() {
protocol_log TEXT NULL
);
''');
rawDb.execute('''
rawDb.execute('''
CREATE TABLE sync_log_mailboxes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
@@ -346,90 +343,79 @@ void main() {
bytes_transferred INTEGER NOT NULL DEFAULT 0
);
''');
rawDb.execute('PRAGMA user_version = 22;');
rawDb.close();
rawDb.execute('PRAGMA user_version = 22;');
rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration.
await db.select(db.accounts).get();
final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration.
await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
// v25: new indexes on mailboxes and threads.
final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get();
final indexNames =
allIndexes.map((r) => r.read<String>('name')).toSet();
expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('threads_latest_date'));
// v25: new indexes on mailboxes and threads.
final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get();
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('threads_latest_date'));
// v26: FTS5 virtual table and triggers.
final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get();
final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet();
expect(
triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
);
await db.customSelect('SELECT count(*) FROM email_fts').get();
// v26: FTS5 virtual table and triggers.
final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get();
final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet();
expect(
triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
);
await db.customSelect('SELECT count(*) FROM email_fts').get();
// v27: search_history_entries table.
await db
.customSelect('SELECT count(*) FROM search_history_entries')
.get();
// v27: search_history_entries table.
await db
.customSelect('SELECT count(*) FROM search_history_entries')
.get();
// v28: mime_tree_json column on email_bodies.
await db
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
.get();
// v28: mime_tree_json column on email_bodies.
await db
.customSelect(
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.get();
// v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = await _tableColumns(
db,
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms'));
// v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns =
await _tableColumns(db, 'sync_log_mailboxes');
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'));
// 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();
// 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'));
// 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'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db
.customSelect('SELECT count(*) FROM image_trusted_senders')
.get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 38', () async {
test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -457,7 +443,6 @@ void main() {
'share_keys', // v31
'local_sieve_applied', // v32
'user_preferences', // v34
'image_trusted_senders', // v37
]),
);
@@ -468,10 +453,8 @@ void main() {
expect(draftColumns, contains('imap_server_id'));
// v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = await _tableColumns(
db,
'sync_log_mailboxes',
);
final syncLogMailboxColumns =
await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
@@ -486,13 +469,6 @@ void main() {
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
await db.close();
});
});
+8 -9
View File
@@ -9,15 +9,14 @@ void main() {
// absent at startup, throwing MissingPluginException (or a similar error).
// initNotifications() must absorb the failure and let the app continue.
test(
'initNotifications completes without throwing when plugin is unavailable',
() async {
// In the unit-test environment the native plugin is not registered, so
// _plugin.initialize() throws. The fix catches it and keeps _initialized
// false. This test fails before the fix (exception propagates) and passes
// after it (exception is swallowed).
await expectLater(initNotifications(), completes);
},
);
'initNotifications completes without throwing when plugin is unavailable',
() async {
// In the unit-test environment the native plugin is not registered, so
// _plugin.initialize() throws. The fix catches it and keeps _initialized
// false. This test fails before the fix (exception propagates) and passes
// after it (exception is swallowed).
await expectLater(initNotifications(), completes);
});
test('showNewMailNotification completes without throwing', () async {
// Platform.isAndroid is false in tests, so this returns early without
@@ -103,9 +103,6 @@ class _FakeEmails implements EmailRepository {
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]);
@override
+10 -7
View File
@@ -26,9 +26,11 @@ class _FakeAccounts implements AccountRepository {
@override
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
@override
Future<Account?> getAccount(String id) async => accounts
.cast<Account?>()
.firstWhere((a) => a?.id == id, orElse: () => null);
Future<Account?> getAccount(String id) async =>
accounts.cast<Account?>().firstWhere(
(a) => a?.id == id,
orElse: () => null,
);
@override
Future<void> addAccount(Account account, String password) async {}
@override
@@ -92,7 +94,11 @@ class _CountingEmails implements EmailRepository {
@override
Future<int> flushPendingChanges(String accountId, String password) async => 0;
@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([]);
@override
Stream<List<EmailThread>> observeThreads(
@@ -102,9 +108,6 @@ class _CountingEmails implements EmailRepository {
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]);
@override
+3 -1
View File
@@ -47,7 +47,9 @@ void main() {
test('parsePublicKeyQr returns null for invalid input', () {
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
expect(
ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
ShareEncryptionService.parsePublicKeyQr(
'sharedinbox.de:pubkey:v1:!!!',
),
isNull,
);
expect(
+7 -5
View File
@@ -73,7 +73,11 @@ void main() {
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
HeaderCondition(
['from', 'reply-to'],
':is',
['boss@work.com'],
),
],
actions: [
FlagAction([r'\Important']),
@@ -117,10 +121,8 @@ void main() {
),
];
final ctx = interp.execute(
rules,
_email(subject: 'Weekly Newsletter Issue'),
);
final ctx =
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
expect(ctx.targetFolders, contains('Bulk'));
});
});

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