Compare commits

..
Author SHA1 Message Date
Thomas SharedInbox 6218335c1d fix(publish-website): survive SSH failure in generate_build_history (#164)
The Dagger container running generate_build_history.py may not always
reach the deployment server (network constraints on the Dagger engine).
Rather than aborting the entire publish-website pipeline, log the SSH
verbose output (already added in the previous debug commit) and return
an empty file list so Hugo still builds and rsync still deploys the
site — just without updated build-history pages.

This unblocks the cron deploy that has been failing since c259d2da.
2026-05-23 12:18:47 +02:00
136 changed files with 2882 additions and 7130 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
+29 -3
View File
@@ -1,14 +1,40 @@
name: CI
on: [push, pull_request]
on:
push:
branches: [main]
pull_request:
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: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+101 -163
View File
@@ -6,233 +6,171 @@ on:
workflow_dispatch:
jobs:
check-changes:
name: Detect Changed Files
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
android: ${{ steps.diff.outputs.android }}
linux: ${{ steps.diff.outputs.linux }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 50
- name: Detect Android and Linux changes
id: diff
shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
- name: Check runner tools
run: |
# On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
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; }
HEAD_SHA=$(git rev-parse HEAD)
- 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
# 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.
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"
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"
]
# 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("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print("")
PYEOF
)
- name: Run Android Tests on Firebase Test Lab
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
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"
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
echo "Changed files:"
echo "$CHANGED"
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
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
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-playstore:
name: Build & Deploy to Play Store
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
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
- 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
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
deploy-apk:
name: Build & Deploy APK to Server
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
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
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.linux == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
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
- 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
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
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]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
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:
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
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
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
runs-on: ubuntu-latest
needs: [deploy-playstore, deploy-apk, build-linux]
if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
)
needs: [test-android-firebase, deploy-playstore, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5
steps:
@@ -241,7 +179,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
-122
View File
@@ -1,122 +0,0 @@
name: Firebase Tests
on:
schedule:
- cron: '0 3 * * *' # once per day at 3 AM
workflow_dispatch:
jobs:
check-changes:
name: Detect Firebase-Relevant Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Firebase-relevant changes in last 24 hours
id: diff
shell: bash
run: |
# On workflow_dispatch always run
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
| sort -u | grep -v '^$')
if [ -n "$CHANGED" ]; then
echo "Firebase-relevant files changed since $SINCE:"
echo "$CHANGED"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
env:
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Create issue on test failure
if: failure()
env:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ["FORGEJO_URL"].rstrip("/")
run_url = os.environ["RUN_URL"]
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
def api_get(path):
req = urllib.request.Request(f"{api}{path}", headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def api_post(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
"Firebase instrumented tests failed in the daily run.\n\n"
f"**Failed run:** {run_url}\n\n"
"## Steps to resolve\n\n"
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
"3. Close this issue once the root cause is resolved and the tests pass.\n"
)
issue = api_post("/issues", {
"title": title,
"body": body,
"labels": label_ids,
})
print(f"Created issue #{issue['number']}: {issue['html_url']}")
PYEOF
-30
View File
@@ -1,30 +0,0 @@
name: Renovate
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Renovate
env:
DAGGER_NO_NAG: "1"
run: task renovate
+8 -19
View File
@@ -1,8 +1,6 @@
name: Update Website
name: Deploy Website
on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push:
branches: [main]
paths:
@@ -13,31 +11,22 @@ on:
jobs:
deploy:
name: Build & Update Website
name: Build & Deploy Website
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
- name: Build & Deploy Website
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Update Website
env:
DAGGER_NO_NAG: "1"
run: task publish-website
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
run: task website-deploy
- name: Verify Website
env:
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh
+3
View File
@@ -11,6 +11,7 @@ jobs:
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
if: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -31,6 +32,7 @@ jobs:
- name: Set up SSH key
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
@@ -40,6 +42,7 @@ jobs:
- name: Deploy Windows to server
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.1"
"flutter": "3.41.6"
}
+6 -7
View File
@@ -202,8 +202,6 @@ jobs:
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: |
@@ -217,20 +215,20 @@ jobs:
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"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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"
ssh -o StrictHostKeyChecking=no "$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"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
- name: Generate build history pages
@@ -246,5 +244,6 @@ jobs:
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
+1 -2
View File
@@ -28,8 +28,7 @@ android/.gradle/
android/local.properties
android/app/google-services.json
android/key.properties
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
android/app/src/main/java/io/flutter/plugins/
.android/
Android/
.gradle/
+2 -2
View File
@@ -33,12 +33,12 @@ repos:
- id: ci-no-direct-dagger
name: check for direct dagger calls in workflows (use Task instead)
language: system
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
pass_filenames: false
always_run: true
- id: dagger-progress-plain
name: ensure all dagger calls use --progress=plain
language: system
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false
always_run: true
+20 -29
View File
@@ -8,41 +8,32 @@ 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/Ready** — Issue is available to pick up
- **State/InProgress** — Set this when you start working on an issue
- **State/Question** — Set this when you hit a blocker or need clarification
**State machine:**
List open issues ready to pick up:
```
loop/plan → loop/plan-in-progress → loop/plan-done
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-progress → loop/code-done
↘ NeedSupervisor (on failure)
```bash
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
```
**Rules:**
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:**
```
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
```
- Never start work on an issue without `State/Ready`
- When working via the agent loop: `State/Ready``State/InProgress` is set automatically
by `agent_loop.py` before the agent starts — do **not** set it 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 -91
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]
@@ -91,93 +91,3 @@ The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger m
- **Check Suite:** Runs analysis and tests in parallel.
- **Builds:** Produces Linux and Android artifacts.
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
## Credential Security — Keeping Production Secrets Off Codeberg
### Problem
The current setup stores two categories of secrets in Codeberg repository secrets:
1. **Dagger access credentials** — TLS certificates used to connect to the remote Dagger engine via stunnel (`DAGGER_CA_CERT`, `DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`, `DAGGER_STUNNEL_URL`).
2. **Production secrets** — actual credentials for external services: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `PLAY_STORE_CONFIG_JSON`, `SSH_PRIVATE_KEY`, `FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY`.
If Codeberg is compromised, both categories are leaked. The Dagger TLS certificates enable access only to the Dagger engine and have limited blast radius. But the production secrets give direct access to the Play Store, the Android signing key, the deployment server, and Firebase — a much larger blast radius.
**Goal:** Keep only Dagger access credentials in Codeberg. Store all production secrets on the Dagger host machine so they never touch Codeberg.
### Option 1: Runner-level environment variables
Store production secrets as environment variables in the Forgejo runner's systemd service (e.g., via a `EnvironmentFile=` in the service override). The runner injects host env vars into job processes automatically. CI workflows drop the `${{ secrets.XYZ }}` references for production secrets entirely — the variables are already present in the job environment.
**Pro:**
- No new infrastructure required.
- Works with the existing `dagger call --progress=plain --secret env:VAR_NAME` argument style.
- Secrets never enter Codeberg.
- Straightforward to set up on a single self-hosted runner.
**Con:**
- Env vars are visible to every process on the runner host (e.g., via `/proc/<pid>/environ`).
- Rotating a secret requires host access (no API).
- Does not scale cleanly to multiple runners without a shared secrets mechanism.
### Option 2: Secret files on the CI host with restricted permissions
Store production secrets as files owned by the runner user with mode `600` (e.g., `/home/forgejo-runner/secrets/play_store.json`). A small setup script reads the files and either exports them as env vars or passes them directly as file-type arguments to `dagger call --progress=plain`. CI workflows contain no secret references at all.
**Pro:**
- OS-level file permissions limit access to the runner user.
- Natural format for JSON payloads and key files.
- Easy to audit (list files, check mtime).
- No new infrastructure.
**Con:**
- Plaintext files on disk; root or backup access exposes them.
- Workflow must know file paths (either hardcoded or by convention).
- Rotation still requires host filesystem access.
### Option 3: Dagger host as pipeline orchestrator
Instead of the CI runner invoking the Dagger CLI directly, the CI job sends a trigger to the Dagger host over SSH. The Dagger host runs the pipeline locally against its own environment, where secrets live as env vars or files. Codeberg only stores the SSH key to reach the Dagger host — not the production secrets.
```yaml
# CI job only does this:
- name: Trigger pipeline on Dagger host
run: ssh dagger-host "cd sharedinbox && task publish-android"
env:
SSH_PRIVATE_KEY: ${{ secrets.DAGGER_TRIGGER_SSH_KEY }}
```
**Pro:**
- Production secrets never leave the Dagger host.
- Codeberg stores exactly one secret: the trigger SSH key.
- All deployment logic and secrets are fully contained on the host.
**Con:**
- Harder to stream structured CI logs back to Codeberg Actions.
- Dynamic context (commit SHA, PR branch) must be passed explicitly over SSH.
- The trigger SSH key still grants shell access to the host, so its compromise has its own blast radius.
- CI becomes a "fire-and-forget" call, making failure attribution harder.
### Option 4: External secret manager (e.g., HashiCorp Vault)
Run a secret manager co-located with the Dagger host. The CI job authenticates with a short-lived AppRole credential (stored in Codeberg) and retrieves secrets at runtime. Vault can also be configured with IP-allow-lists to further restrict who can authenticate.
**Pro:**
- Full audit trail: every secret read is logged with a timestamp and caller identity.
- Fine-grained access control per secret.
- Built-in versioning and rotation support.
- Industry-standard approach; scales to team or multi-runner setups.
**Con:**
- Significant additional infrastructure to install, configure, and maintain.
- Vault credentials (RoleID + SecretID) still need to be in Codeberg, though with a smaller blast radius than raw secrets.
- Vault itself becomes a security-critical single point of failure.
- Operational overhead likely disproportionate for a small single-developer project.
### Recommendation
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
-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
+20 -71
View File
@@ -215,16 +215,14 @@ tasks:
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- 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=. 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 --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
cmds:
- mkdir -p build/app/outputs/bundle/release
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -238,7 +236,6 @@ tasks:
publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
deps: [generate-changelog]
preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set"
@@ -247,31 +244,27 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
deploy-apk:
desc: Build and deploy Android APK via Dagger
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
msg: "ANDROID_KEYSTORE_BASE64 is not set"
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- 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)"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --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
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- 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 --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,13 +287,8 @@ 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" "$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
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else
return "$RC"
fi
@@ -319,16 +307,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" \
@@ -339,20 +318,6 @@ tasks:
wait "$RECV_PID" 2>/dev/null || true
exit $RC
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
renovate:
desc: Run Renovate bot against the repository via Dagger
preconditions:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup]
@@ -400,29 +365,25 @@ tasks:
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:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
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"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
# Merge with any existing latest.json so we don't overwrite the windows key
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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"
ssh -o StrictHostKeyChecking=no "$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"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
echo "Uploaded $TARBALL and updated latest.json"
@@ -447,28 +408,24 @@ tasks:
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:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
if [ -n "$LINUX_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
echo "Uploaded $ZIPFILE and updated latest.json"
@@ -618,18 +575,14 @@ tasks:
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:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
APK_NAME="sharedinbox-mua-$HASH.apk"
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no \
build/app/outputs/flutter-apk/app-release.apk \
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
echo "Uploaded $APK_NAME to $REMOTE_DIR"
@@ -658,16 +611,12 @@ tasks:
website-deploy:
desc: Deploy the website via rsync to public_html
deps: [website-build]
preconditions:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \
${SSH_USER}@${SSH_HOST}:public_html/
+1
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
+3 -5
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 {
@@ -69,7 +67,7 @@ flutter {
dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
// integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation
@@ -1,89 +0,0 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
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.3.21" 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")
+27 -19
View File
@@ -7,8 +7,8 @@ require (
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.44.0
go.opentelemetry.io/otel/trace v1.44.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)
require (
@@ -21,25 +21,33 @@ require (
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.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/log v0.20.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // 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
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-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.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.16.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
-32
View File
@@ -43,65 +43,36 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
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 v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
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/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
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/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
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/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
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/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
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 v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
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/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
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/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
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/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
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/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
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 v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
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 v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
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/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
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/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
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=
@@ -116,13 +87,10 @@ 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/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
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/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/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/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
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=
+49 -160
View File
@@ -181,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:3.44.0").
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"}).
@@ -195,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
WithUser("ci").
WithExec([]string{"/bin/sh", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
}
// Base is the Flutter toolchain container with mutable cache mounts attached.
@@ -222,7 +221,7 @@ func (m *Ci) pubGetLayer() *dagger.Container {
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^(\+|Downloading packages)' "$tmp" || true`}).
`grep -vE '^[+~><] ' "$tmp" || true`}).
WithExec([]string{"python3", "-c",
"import json, os\n" +
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
@@ -246,7 +245,7 @@ func (m *Ci) codegenBase() *dagger.Container {
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`})
`grep -vE '^\[' "$tmp" || true`})
}
// setup overlays platform-specific source files onto the shared codegen base.
@@ -286,21 +285,6 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
})
}
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func (m *Ci) androidBase() *dagger.Container {
return m.setup(m.androidSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func (m *Ci) firebaseBase() *dagger.Container {
return m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// linuxSrc is the source subset for Linux builds and integration tests.
func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
@@ -328,24 +312,17 @@ func (m *Ci) Hugo() *dagger.Container {
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
WithExec([]string{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}).
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
}
// Deploy container for rsync/ssh
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
// Mount at a raw path so we can normalise before use: strip any CRLF line
// endings that appear when the key is stored or exported on Windows, which
// cause "error in libcrypto" in Alpine's LibreSSL-backed openssh.
WithMountedSecret("/root/.ssh/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithExec([]string{"sh", "-c",
"tr -d '\\r' < /root/.ssh/id_ed25519.raw > /root/.ssh/id_ed25519 && chmod 600 /root/.ssh/id_ed25519"}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
}
// Stalwart mail server service for backend and integration tests.
@@ -433,7 +410,7 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
`grep -vE '^\[' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
Stdout(ctx)
}
@@ -485,18 +462,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())
@@ -520,19 +490,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 {
@@ -546,7 +513,6 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
func (m *Ci) GenerateBuildHistory(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
) *dagger.Directory {
@@ -558,7 +524,7 @@ func (m *Ci) GenerateBuildHistory(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithExec([]string{"chmod", "700", "/root/.ssh"}).
WithEnvVariable("SSH_USER", sshUser).
WithEnvVariable("SSH_HOST", sshHost).
WithDirectory("/src", scriptSource).
@@ -571,25 +537,18 @@ func (m *Ci) GenerateBuildHistory(
func (m *Ci) BuildWebsite(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
// +optional
commitHash string,
) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
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")
}
@@ -598,15 +557,12 @@ func (m *Ci) BuildWebsite(
func (m *Ci) PublishWebsite(
ctx context.Context,
sshKey *dagger.Secret,
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, sshUser, sshHost)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithDirectory("/public", public).
WithExec([]string{"rsync", "-avz", "--delete",
"--exclude=*.apk", "--exclude=*.tar.gz",
@@ -622,17 +578,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
}
// BuildLinuxRelease builds the Linux release bundle.
func (m *Ci) BuildLinuxRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.Directory {
args := []string{"flutter", "build", "linux", "--release"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
return m.setup(m.linuxSrc()).
WithExec(args).
WithExec([]string{"flutter", "build", "linux", "--release"}).
Directory("build/linux/x64/release/bundle")
}
@@ -640,48 +588,36 @@ func (m *Ci) BuildLinuxRelease(
func (m *Ci) DeployLinux(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
commitHash string,
) (string, error) {
bundle := m.BuildLinuxRelease(commitHash)
bundle := m.BuildLinuxRelease()
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithDirectory("/bundle", bundle).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
Stdout(ctx)
}
// setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.androidBase().
return m.setup(m.androidSrc()).
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
}
// BuildAndroidApk builds a release APK signed with the upload key.
func (m *Ci) BuildAndroidApk(
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec(args).
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
File("build/app/outputs/flutter-apk/app-release.apk")
}
@@ -689,7 +625,6 @@ func (m *Ci) BuildAndroidApk(
func (m *Ci) DeployApk(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
commitHash string,
@@ -697,28 +632,26 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret,
buildNumber string,
) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
return m.Deployer(sshKey, knownHosts).
return m.Deployer(sshKey).
WithFile("/tmp/app.apk", apk).
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
Stdout(ctx)
}
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.firebaseBase().
built := m.setup(m.firebaseSrc()).
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was
// preserved in the Dagger layer snapshot but whose process no longer exists.
WithExec([]string{"./gradlew", "--no-daemon", "app:assembleAndroidTest"}).
WithExec([]string{"./gradlew", "app:assembleAndroidTest"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c",
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
@@ -772,17 +705,9 @@ func (m *Ci) TestAndroidFirebase(
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
func (m *Ci) BuildAndroidRelease() *dagger.File {
return m.setup(m.androidSrc()).
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
File("build/app/outputs/bundle/release/app-release.aab")
}
@@ -810,7 +735,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithExec([]string{"pip", "install", "requests", "google-auth"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
@@ -854,41 +779,14 @@ func (m *Ci) PublishAndroid(
playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) (string, error) {
versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease(commitHash)
aab := m.BuildAndroidRelease()
stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
}
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
return dag.Container().
From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
// Graph returns a Mermaid diagram of the CI pipeline structure.
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
// or save it as a .md file to get a rendered diagram.
@@ -902,7 +800,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
@@ -933,25 +831,16 @@ flowchart TD
integration --> check
end
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
ciCheck["check"]
end
buildLinux["build-linux\n(main only)"]
deployPS["deploy-playstore\n(main only)"]
pubWeb["publish-website\n(main only)"]
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
detectChanges["check-changes\ndetect android / linux diff"]
buildLinux["build-linux\n(linux changed)"]
deployPS["deploy-playstore\n(android changed)"]
deployApk["deploy-apk\n(android changed)"]
fbTest["test-android-firebase\n(android changed)"]
pubWeb["publish-website\n(any build succeeded)"]
detectChanges --> buildLinux
detectChanges --> deployPS
detectChanges --> deployApk
detectChanges --> fbTest
ciCheck --> buildLinux
ciCheck --> deployPS
buildLinux --> pubWeb
deployPS --> pubWeb
deployApk --> pubWeb
end
check -- "task check-dagger" --> ciCheck
+1 -1
View File
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
# Add nix profile and nix store tools (task, dagger) to PATH
export PATH="$HOME/.nix-profile/bin:$PATH"
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
done
+106 -14
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env python3
"""
Cron deploy script for sharedinbox website.
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
Forgejo Actions handles failure reporting.
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
"""
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO_DIR = Path(__file__).parent.resolve()
SHA_FILE = REPO_DIR / '.last_deployed_sha'
SHA_FILE = REPO_DIR / '.last_deployed_sha'
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
ERROR_FILE = REPO_DIR / '.last_deploy_error'
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
MAX_FAILURES = 5
REPO = 'guettli/sharedinbox'
CODEBERG = 'https://codeberg.org'
def git(*args):
@@ -25,30 +32,115 @@ def read(path: Path) -> str:
return path.read_text().strip() if path.exists() else ''
def main():
def read_int(path: Path) -> int:
try:
git('fetch', 'origin', 'main')
except subprocess.CalledProcessError as exc:
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
return
return int(read(path))
except ValueError:
return 0
def issue_exists_for(sha: str) -> bool:
"""Check Codeberg for an open issue referencing this commit SHA."""
result = subprocess.run(
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
'--limit', '50', '--output', 'simple'],
capture_output=True, text=True,
)
return sha[:8] in result.stdout
def create_issue(failed_sha: str, fail_count: int) -> None:
error_output = read(ERROR_FILE)
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
body = f"""\
## Deploy failure — action needed
The automated deploy cron failed **{fail_count} times** on commit \
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
| | |
|---|---|
| **Detected** | {timestamp} |
| **Failing commit** | [{failed_sha}]({commit_url}) |
| **Failures** | {fail_count} / {MAX_FAILURES} |
| **Deploy script** | [deploy_cron.py]({script_url}) |
| **Log file** | `~/si-deploy-cron/deploy.log` |
### Last deploy output
```
{tail}
```
### Next steps
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
"""
result = subprocess.run(
['tea', 'issue', 'create',
'--repo', REPO,
'--title', title,
'--description', body,
'--labels', 'State/Ready,Prio/High'],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
else:
print(f'Issue created: {result.stdout.strip()}')
def main():
git('fetch', 'origin', 'main')
remote_sha = git('rev-parse', 'origin/main')
last_sha = read(SHA_FILE)
last_sha = read(SHA_FILE)
last_failed = read(FAILED_SHA_FILE)
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
last_issue = read(ISSUE_SHA_FILE)
if remote_sha == last_sha:
print(f'No changes since {remote_sha[:8]}, skipping.')
return
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
if fail_count >= MAX_FAILURES:
if remote_sha != last_issue and not issue_exists_for(remote_sha):
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
create_issue(remote_sha, fail_count)
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
else:
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
return
attempt = fail_count + 1
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
git('pull', '--ff-only', 'origin', 'main')
result = subprocess.run(
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
['task', 'publish-website'],
cwd=REPO_DIR,
capture_output=True, text=True,
)
combined = result.stdout + result.stderr
print(combined, end='')
if result.returncode != 0:
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
FAILED_SHA_FILE.write_text(remote_sha + '\n')
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
ERROR_FILE.write_text(combined)
sys.exit(1)
SHA_FILE.write_text(remote_sha + '\n')
print('Workflow triggered.')
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
f.unlink(missing_ok=True)
print('Deploy complete.')
if __name__ == '__main__':
-22
View File
@@ -4,28 +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
dependencies up to date. All required components are in main:
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
- `ci/main.go``Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
- `Taskfile.yml``renovate` task
- Issue #257 closed.
## Tasks (2026-05-11)
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
+2 -3
View File
@@ -94,9 +94,8 @@
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
google-auth
requests
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]);
+2 -2
View File
@@ -317,7 +317,7 @@ void main() {
// ── Check Sent folder ──────────────────────────────────────────────────
// Use the drawer to switch folders (no back button on Linux desktop).
await tester.tap(find.byTooltip('Open folders'));
await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sent'));
await tester.pumpAndSettle();
@@ -331,7 +331,7 @@ void main() {
expect(find.text(subject), findsOneWidget);
// ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open folders'));
await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle();
-1
View File
@@ -1 +0,0 @@
const int dbSchemaVersion = 37;
-14
View File
@@ -1,14 +0,0 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
}
@@ -15,10 +15,6 @@ abstract class EmailRepository {
int limit = 50,
});
/// Returns threads from the INBOX of every account, sorted by latest date.
/// Inbox mailboxes are matched by role = 'inbox'.
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
/// Returns all emails belonging to [threadId] in [mailboxPath].
Stream<List<Email>> observeEmailsInThread(
String accountId,
@@ -11,13 +11,4 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId);
/// Creates a new mailbox named [name] for [accountId] and tags it with
/// [role] in the local database. For JMAP accounts the role is also sent
/// to the server. Returns the newly created [Mailbox].
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
);
}
@@ -19,8 +19,6 @@ class SyncLogEntry {
required this.id,
required this.result,
this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol,
required this.emailsFetched,
required this.emailsSkipped,
@@ -36,8 +34,6 @@ class SyncLogEntry {
final int id;
final String result; // 'ok' or 'error'
final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap'
final int emailsFetched;
final int emailsSkipped;
@@ -58,8 +54,6 @@ abstract class SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -87,8 +81,6 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -1,12 +0,0 @@
import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
Future<void> removeTrustedImageSender(String senderEmail);
}
+5 -5
View File
@@ -13,7 +13,7 @@ Future<void> initNotifications() async {
try {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
settings: const InitializationSettings(android: android),
const InitializationSettings(android: android),
onDidReceiveNotificationResponse: (_) {},
);
await _plugin
@@ -31,10 +31,10 @@ Future<void> initNotifications() async {
Future<void> showNewMailNotification(String accountEmail) async {
if (!Platform.isAndroid || !_initialized) return;
await _plugin.show(
id: accountEmail.hashCode & 0x7FFFFFFF,
title: 'New mail',
body: accountEmail,
notificationDetails: const NotificationDetails(
accountEmail.hashCode & 0x7FFFFFFF,
'New mail',
accountEmail,
const NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
@@ -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),
+15 -16
View File
@@ -4,39 +4,38 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
class UndoService extends Notifier<List<UndoAction>> {
class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
static const int _maxHistory = 10;
// Resolves once build() has loaded persisted history.
late Future<void> _ready;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
@override
List<UndoAction> build() {
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
if (ref.mounted) state = history;
Future<void> init() async {
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
});
return [];
await _ready;
}
/// Waits for the persisted history to finish loading. Called by tests to
/// ensure the provider is ready before asserting state.
Future<void> init() => _ready;
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
}
state = newList;
await ref.read(undoRepositoryProvider).saveAction(action);
await _ref.read(undoRepositoryProvider).saveAction(action);
}
Future<void> clear() async {
await _ready;
state = [];
unawaited(ref.read(undoRepositoryProvider).clearHistory());
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
@@ -58,7 +57,7 @@ class UndoService extends Notifier<List<UndoAction>> {
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
// the local change). The inverse action added below allows undoing the undo.
final repo = ref.read(emailRepositoryProvider);
final repo = _ref.read(emailRepositoryProvider);
for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet).
+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();
-7
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -260,8 +259,6 @@ class _AccountSync implements _SyncLoop {
accountId: account.id,
success: false,
errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
@@ -297,7 +294,6 @@ class _AccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') ||
@@ -515,8 +511,6 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id,
success: false,
errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap',
emailsFetched: 0,
emailsSkipped: 0,
@@ -552,7 +546,6 @@ class _JmapAccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
return s.contains('invalid credentials') ||
s.contains('authentication failed') ||
-4
View File
@@ -6,7 +6,6 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -25,9 +24,6 @@ const _kResourceType = 'background_check';
@pragma('vm:entry-point')
void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((_, __) async {
try {
await _doBackgroundSync();
+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;
}
+3 -111
View File
@@ -6,7 +6,6 @@ import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
part 'database.g.dart';
@@ -193,9 +192,6 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()();
// Added in schema v33: stack trace and permanent flag for error entries.
TextColumn get errorStackTrace => text().nullable()();
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
}
/// Per-mailbox breakdown for a single sync cycle.
@@ -307,34 +303,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 {
IntColumn get id => integer()();
// 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase(
@@ -355,15 +323,13 @@ class UserPreferences extends Table {
LocalSieveScripts,
LocalSieveApplied,
ShareKeys,
UserPreferences,
ImageTrustedSenders,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => dbSchemaVersion;
int get schemaVersion => 32;
Future<void> _createEmailFts() async {
await customStatement('''
@@ -604,28 +570,6 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) {
await m.createTable(localSieveApplied);
}
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
if (from < 34) {
await m.createTable(userPreferences);
}
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
if (from < 37) {
await m.createTable(imageTrustedSenders);
}
},
);
}
@@ -652,10 +596,8 @@ Future<void> initDatabasePath() async {
Future<String> _resolveDatabasePath() async {
if (_dbPath != null) return _dbPath!;
// initDatabasePath() failed (channel not ready before runApp). Retry now
// that the engine is fully initialised, with back-off. Some slow Android
// devices need several seconds for the Pigeon channel to become ready
// (issue #166), so use a longer schedule than the initial attempt.
const delays = [200, 500, 1000, 2000, 4000];
// that the engine is fully initialised, with brief back-off.
const delays = [100, 300, 600];
for (final ms in delays) {
try {
final dir = await getApplicationSupportDirectory();
@@ -665,17 +607,6 @@ Future<String> _resolveDatabasePath() async {
await Future<void>.delayed(Duration(milliseconds: ms));
}
}
// On Android, path_provider can be permanently broken on some devices
// regardless of how long we wait (issue #192). Derive the path from
// /proc/self/cmdline (the Android process name == package name) without
// a platform channel as a last resort so the app can still open its DB.
if (Platform.isAndroid) {
final fallback = await _androidFallbackPath();
if (fallback != null) {
_dbPath = fallback;
return _dbPath!;
}
}
throw PlatformException(
code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
@@ -683,45 +614,6 @@ Future<String> _resolveDatabasePath() async {
);
}
// Reads /proc/self/cmdline to extract the Android package name, then
// constructs the standard app files-dir path without a platform channel.
// Returns null when the path cannot be determined or created.
Future<String?> _androidFallbackPath() async {
try {
final bytes = await File('/proc/self/cmdline').readAsBytes();
final end = bytes.indexOf(0);
final packageName = String.fromCharCodes(
end >= 0 ? bytes.sublist(0, end) : bytes,
).trim();
// A valid Android package name contains dots but not slashes.
if (packageName.isEmpty ||
!packageName.contains('.') ||
packageName.contains('/')) {
return null;
}
for (final base in [
'/data/user/0/$packageName/files',
'/data/data/$packageName/files',
]) {
try {
await Directory(base).create(recursive: true);
return p.join(base, 'sharedinbox.db');
} catch (_) {
continue;
}
}
return null;
} catch (_) {
return null;
}
}
// These functions are only called from unit tests (database_path_test.dart).
// They expose internals that cannot be reached via the public API.
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null;
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final file = File(await _resolveDatabasePath());
+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 {
@@ -79,15 +79,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
);
try {
final mailboxes = await client.listMailboxes(recursive: true);
// Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(
_db.mailboxes,
)..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) {
final path = mb.path;
final id = '${account.id}:$path';
@@ -105,12 +96,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e');
}
// Use the server-assigned role when available; fall back to the
// existing DB role so that manually-created folders (e.g. a user
// who just created their Archive folder) keep their role across syncs
// when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id];
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
@@ -119,7 +104,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name,
unreadCount: Value(unread),
totalCount: Value(total),
role: Value(role),
role: Value(_imapRole(mb)),
),
);
}
@@ -321,112 +306,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();
}
@override
Future<model.Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, role);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, role);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String role,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.createMailbox(name);
} finally {
await client.logout();
}
final id = '${account.id}:$name';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: name,
name: name,
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
Future<model.Mailbox> _createMailboxWithRoleJmap(
account_model.Account account,
String password,
String name,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final responses = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
],
]);
final result = _responseArgs(responses, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newId =
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
if (newId == null) {
throw Exception(
'Failed to create mailbox "$name": server returned no ID',
);
}
final dbId = '${account.id}:$newId';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: account.id,
path: newId,
name: name,
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
}
@@ -24,9 +24,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
await _db.transaction(() async {
// 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();
}
@@ -13,8 +13,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -32,8 +30,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId,
result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol),
itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped),
@@ -79,8 +75,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id,
result: r.result,
errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol,
emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped,
@@ -1,95 +0,0 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
UserPreferencesRepositoryImpl(this._db);
final AppDatabase _db;
static const _rowId = 1;
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@override
Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
menuPosition: Value(position.name),
),
);
}
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
@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(
menuPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom,
),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
);
}
}
+18 -72
View File
@@ -5,16 +5,13 @@ import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -23,8 +20,7 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart'
hide Email, EmailBody, UserPreferences;
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
@@ -36,7 +32,6 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
/// Swappable IMAP connection factory — override in tests to use plaintext.
@@ -101,13 +96,12 @@ 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));
});
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -136,10 +130,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 +180,12 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
});
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
UndoService.new,
);
final undoServiceProvider =
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
final service = UndoService(ref);
unawaited(service.init());
return service;
});
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -199,50 +194,20 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new,
);
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
@override
Future<(Email?, EmailBody)> build() async {
Future<(Email?, EmailBody)> build(String emailId) async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(_emailId),
repo.getEmailBody(_emailId),
repo.getEmail(emailId),
repo.getEmailBody(emailId),
]);
unawaited(repo.setFlag(_emailId, seen: true));
final header = results[0] as Email?;
if (header != null) {
unawaited(_prefetchNextEmailBody(repo, header));
}
unawaited(repo.setFlag(emailId, seen: true));
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(
@@ -263,22 +228,3 @@ final accountConnectionStatusProvider =
.read(connectionTestServiceProvider)
.testConnection(account, password);
});
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
ref,
) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
ref,
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
final trustedImageSendersProvider =
StreamProvider.autoDispose<List<String>>((ref) {
return ref
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
-1
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
+1 -11
View File
@@ -4,7 +4,6 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
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';
@@ -21,19 +20,14 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
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(),
@@ -62,10 +56,6 @@ final router = GoRouter(
path: 'about',
builder: (ctx, state) => const AboutScreen(),
),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
+55 -68
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -7,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget {
@@ -19,22 +19,53 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture;
late final Stream<List<Account>> _accountsStream;
String? _deviceModel;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override
void initState() {
super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
_deviceModelFuture = getDeviceModel();
unawaited(
_deviceModelFuture.then((model) {
if (mounted) setState(() => _deviceModel = model);
}),
);
}
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
return '## sharedinbox.de\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard(
BuildContext context,
int imapCount,
@@ -44,20 +75,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try {
pkg = await _packageInfoFuture;
} catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return;
await Clipboard.setData(
ClipboardData(
text: buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
),
);
if (context.mounted) {
@@ -70,32 +91,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
}
}
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
Future<void> _createIssue(
BuildContext context,
int imapCount,
@@ -105,28 +100,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try {
pkg = await _packageInfoFuture;
} catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return;
final body = Uri.encodeComponent(
buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
_buildMarkdown(context, pkg, imapCount, jmapCount),
);
final url = Uri.parse(
'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(
@@ -170,17 +153,21 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
return const Center(child: CircularProgressIndicator());
}
return Markdown(
data: buildAboutMarkdown(
context: context,
pkg: snapshot.data,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
data: _buildMarkdown(
context,
snapshot.data,
imapCount,
jmapCount,
),
selectable: true,
onTapLink: (text, href, title) {
if (href != null) {
unawaited(_launchUrl(context, Uri.parse(href)));
unawaited(
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
);
+71 -112
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -67,14 +66,6 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/about'));
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/preferences'));
},
),
],
),
),
@@ -120,80 +111,20 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id));
final 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];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Sync health: '),
Icon(
@@ -202,13 +133,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange,
),
const SizedBox(width: 4),
Expanded(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
Text(' ($date)', style: const TextStyle(fontSize: 10)),
],
);
@@ -216,8 +141,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'),
);
}
@@ -310,30 +293,6 @@ class _AccountTile extends ConsumerWidget {
}
}
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget {
const _OnboardingView();
+13 -75
View File
@@ -32,15 +32,11 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr;
String? _errorMessage;
bool _scannerActive = false;
MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override
void initState() {
@@ -65,7 +61,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
);
setState(() {
_keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr;
_step = _Step.showingPubKey;
});
@@ -81,37 +76,8 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
setState(() {
_step = _Step.scanning;
_scannerActive = true;
_scannerController = MobileScannerController();
});
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
}
Future<void> _onScanned(String rawValue) async {
@@ -219,7 +185,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(
@@ -274,7 +244,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
},
),
const SizedBox(height: 8),
_ExpiryHint(expiresAt: _keyExpiresAt!),
const _ExpiryHint(),
const SizedBox(height: 32),
if (_errorMessage != null) ...[
Text(
@@ -296,14 +266,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}
Widget _buildScannerView(BuildContext context) {
// Fall back to text input when the platform has no camera support or when
// the scanner plugin fails to initialise at runtime (MissingPluginException).
if (!_cameraScanSupported() || _scannerFailed) {
// On platforms where the camera scanner is not available (Linux desktop),
// fall back to a text-input field.
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
@@ -404,37 +371,8 @@ bool _cameraScanSupported() =>
Platform.isMacOS ||
Platform.isWindows;
class _ExpiryHint extends StatefulWidget {
const _ExpiryHint({required this.expiresAt});
final DateTime expiresAt;
@override
State<_ExpiryHint> createState() => _ExpiryHintState();
}
class _ExpiryHintState extends State<_ExpiryHint> {
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String _formatRemaining() {
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
class _ExpiryHint extends StatelessWidget {
const _ExpiryHint();
@override
Widget build(BuildContext context) {
@@ -444,7 +382,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'This key expires in ${_formatRemaining()}',
'This key expires in 20 minutes',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
+9 -37
View File
@@ -45,42 +45,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
bool _scannerActive = true;
MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override
void initState() {
super.initState();
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
_scannerController = MobileScannerController();
}
}
@@ -158,7 +128,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,
),
);
}
@@ -205,12 +178,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
}
Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported() || _scannerFailed) {
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
@@ -358,7 +328,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',
),
),
);
},
+2 -3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -12,9 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future: DefaultAssetBundle.of(
context,
).loadString('assets/changelog.txt'),
future: rootBundle.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)),
],
),
);
}
}
+10 -4
View File
@@ -162,7 +162,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
}
Future<void> _pickAttachments() async {
final result = await FilePicker.pickFiles();
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return;
final files = result.files.where((f) => f.path != null).toList();
if (!mounted) return;
@@ -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'),
+7 -96
View File
@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -11,45 +10,21 @@ class CrashScreen extends StatelessWidget {
super.key,
required this.exception,
required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
});
final Object exception;
final StackTrace? stackTrace;
final String gitHash;
String get _buildMode {
if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _fetchVersion() async {
try {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
} catch (_) {
return 'unknown';
}
}
Future<String> _buildReport() async {
final version = await _fetchVersion();
String version = 'unknown';
try {
final info = await PackageInfo.fromPlatform();
version = '${info.version}+${info.buildNumber}';
} catch (_) {}
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final versionDisplay = gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
: version;
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: '';
final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n'
'Build Mode: $_buildMode\n'
'$gitLine'
'Platform: $platform\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
return 'App Version: $version\n'
'Platform: $platform\n\n'
'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```';
}
@@ -75,70 +50,6 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
FutureBuilder<String>(
future: _fetchVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
),
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final version =
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
return GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'App Version: $version',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
},
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'Git Commit: $gitHash',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 24),
const Text(
'Error Details:',
+4 -21
View File
@@ -38,7 +38,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
var _sieveSsl = true;
var _verbose = false;
final _jmapUrlCtrl = TextEditingController();
bool _hasStoredPassword = false;
// -- "Try connection" state ------------------------------------------------
bool _tryTesting = false;
@@ -51,7 +50,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load());
}
@@ -65,11 +63,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
context.pop();
return;
}
try {
await repo.getPassword(account.id);
_hasStoredPassword = true;
} catch (_) {}
if (!mounted) return;
_account = account;
_displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username;
@@ -91,7 +84,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [
_displayNameCtrl,
_usernameCtrl,
@@ -275,12 +267,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
),
_field(
_passwordCtrl,
_hasStoredPassword
? 'New password (leave blank to keep)'
: 'Password',
'New password (leave blank to keep)',
key: const Key('editPasswordField'),
obscure: true,
required: !_hasStoredPassword,
required: false,
),
if (account.type == AccountType.jmap) ...[
const Divider(height: 32),
@@ -355,17 +345,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting,
okMessage: _tryOk,
errorMessage: _tryErr,
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _tryConnection
: null,
onPressed: _tryConnection,
),
const SizedBox(height: 8),
FilledButton(
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _save
: null,
child: const Text('Save'),
),
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
),
-80
View File
@@ -1,80 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m in mailboxes.where(
(m) => m.path != currentMailboxPath,
))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+130 -375
View File
@@ -13,12 +13,9 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/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';
@@ -46,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.value?.$1;
final email = next.valueOrNull?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
},
);
final header = detail.value?.$1;
final body = detail.value?.$2;
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
@@ -73,23 +70,61 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null
? null
: () {
unawaited(_replyWithRecipientDialog(context, header, body));
unawaited(_reply(context, header, body, replyAll: false));
},
),
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: header == null
? null
: () {
unawaited(_archive(context, header));
unawaited(_reply(context, header, body, replyAll: true));
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
tooltip: 'Mark as unread',
onPressed: () async {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
@@ -108,32 +143,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
if (context.mounted) _navigateTo(context, header, nextEmailId);
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
if (context.mounted) context.pop();
},
),
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'),
@@ -142,22 +156,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure',
child: Text('Show Mail Structure'),
),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
const PopupMenuItem(
value: 'rfc',
child: Text('Show Raw Email'),
),
],
onSelected: (value) async {
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') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) {
onSelected: (value) {
if (value == 'headers' && body != null) {
_showHeaders(context, body);
} else if (value == 'structure' && body != null) {
_showStructure(context, body);
@@ -171,35 +176,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(
@@ -207,40 +196,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(
@@ -279,40 +241,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(widget.emailId),
);
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename));
try {
@@ -375,78 +303,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return '\n\n— On $date, $from wrote:\n$quoted';
}
Future<void> _replyWithRecipientDialog(
BuildContext context,
Email header,
EmailBody? body,
) async {
final account =
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
final ownEmail = account?.email.toLowerCase() ?? '';
final seen = <String>{};
final candidates = <_Candidate>[];
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
final key = addr.email.toLowerCase();
if (key == ownEmail || seen.contains(key)) return;
seen.add(key);
candidates.add(_Candidate(addr, defaultPlacement));
}
for (final addr in header.from) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.to) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.cc) {
addIfNew(addr, _Placement.cc);
}
if (!context.mounted) return;
if (candidates.length <= 1) {
final to = candidates
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = candidates
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
return;
}
final confirmed = await showDialog<List<_Candidate>>(
context: context,
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
);
if (confirmed == null || !context.mounted) return;
final to = confirmed
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = confirmed
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
}
Future<void> _composeReply(
Future<void> _reply(
BuildContext context,
Email header,
EmailBody? body, {
required String to,
required String cc,
required bool replyAll,
}) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject!
: 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited(
@@ -463,78 +330,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<void> _archive(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _forward(
BuildContext context,
Email header,
@@ -548,14 +343,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(
context.push(
'/compose',
extra: {'prefillSubject': subject, 'prefillBody': quoted},
extra: {
'prefillSubject': subject,
'prefillBody': quoted,
},
),
);
}
Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -604,13 +400,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
if (context.mounted) context.pop();
}
Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>(
context: context,
builder: (ctx) => const SnoozePicker(),
@@ -638,7 +431,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
),
);
_navigateTo(context, header, nextEmailId);
context.pop();
}
}
@@ -650,9 +443,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;
}
@@ -766,7 +559,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'),
),
],
),
),
);
}
@@ -777,7 +610,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;
@@ -789,13 +624,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];
@@ -824,90 +658,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
},
),
),
),
),
);
}
}
enum _Placement { to, cc, skip }
class _Candidate {
_Candidate(this.address, this.placement);
final EmailAddress address;
_Placement placement;
}
class _ReplyAllDialog extends StatefulWidget {
const _ReplyAllDialog({required this.candidates});
final List<_Candidate> candidates;
@override
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
}
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
late final List<_Candidate> _candidates;
@override
void initState() {
super.initState();
_candidates = [
for (final c in widget.candidates) _Candidate(c.address, c.placement),
];
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Reply All'),
content: SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: [
for (final c in _candidates)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
c.address.toString(),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
SegmentedButton<_Placement>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: _Placement.to, label: Text('To')),
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
ButtonSegment(
value: _Placement.skip,
label: Text('Skip'),
),
],
selected: {c.placement},
onSelectionChanged: (s) =>
setState(() => c.placement = s.first),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _candidates),
child: const Text('Reply'),
),
],
);
}
}
@@ -954,13 +712,10 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink();
return Tooltip(
message: uri.toString(),
child: ActionChip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
return ActionChip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
);
}
}
+35 -63
View File
@@ -8,10 +8,8 @@ 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/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -149,21 +147,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold(
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
appBar: _buildAppBar(repo, accountAsync),
drawer: _selecting
? null
: FolderDrawer(
accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: Column(
children: [
_buildSyncErrorBanner(),
@@ -179,14 +172,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, {
required bool menuAtBottom,
}) {
AsyncValue<Account?> accountAsync,
) {
final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting
? IconButton(
icon: const Icon(Icons.close),
@@ -270,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
@@ -309,22 +300,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
@@ -375,13 +350,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.value;
final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
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 +374,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(
@@ -440,26 +420,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Future<void> _batchMoveToRole(
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
final ids = _selectedEmailIds;
_clearSelection();
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
widget.accountId,
widget.mailboxPath,
role,
dialogTitle: dialogTitle,
createFolderName: createFolderName,
);
if (!mounted || mailbox == null) return;
final mailbox = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, role);
if (!mounted) return;
if (mailbox == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text(notFoundMessage),
),
);
return;
}
final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -485,11 +463,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() => _batchMoveToRole(
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _batchArchive() =>
_batchMoveToRole('archive', 'No archive folder found');
Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return;
@@ -568,11 +543,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
}
Future<void> _batchMarkSpam() => _batchMoveToRole(
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMarkSpam() =>
_batchMoveToRole('junk', 'No spam folder found');
Future<void> _batchMove() async {
final ids = _selectedEmailIds;
-18
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'),
actions: [
IconButton(
@@ -47,19 +42,6 @@ class MailboxListScreen extends ConsumerWidget {
],
),
drawer: FolderDrawer(accountId: accountId),
bottomNavigationBar: menuAtBottom
? BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
],
),
)
: null,
body: Column(
children: [
// ── Failed-mutation banner ───────────────────────────────────────
+2 -3
View File
@@ -10,9 +10,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/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(
+5 -139
View File
@@ -1,15 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -25,57 +21,6 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
final buf = StringBuffer();
buf.writeln('## Sync Entry');
buf.writeln();
buf.writeln('| Property | Value |');
buf.writeln('|----------|-------|');
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
if (entry.protocol.isNotEmpty) {
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
}
final statusLabel = entry.isOk
? 'OK'
: entry.isPermanent
? 'Error (permanent)'
: 'Error';
buf.writeln('| Status | $statusLabel |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
if (entry.mailboxStats.isNotEmpty) {
buf.writeln();
buf.writeln('### Per mailbox');
buf.writeln();
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
buf.writeln('|---------|---------|------------|----------|');
for (final m in entry.mailboxStats) {
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
}
}
if (entry.errorMessage != null) {
buf.writeln();
buf.writeln('**Error:**');
buf.writeln();
buf.writeln(entry.errorMessage);
}
if (entry.stackTrace != null) {
buf.writeln();
buf.writeln('**Stack trace:**');
buf.writeln();
buf.writeln('```');
buf.write(entry.stackTrace);
buf.writeln('```');
}
return buf.toString();
}
class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId});
@@ -124,41 +69,6 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId);
}
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
PackageInfo? pkg;
try {
pkg = await PackageInfo.fromPlatform();
} catch (_) {}
final deviceModel = await getDeviceModel();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
);
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 3),
content: Text('Copied to clipboard'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -186,20 +96,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet'))
: ListView.builder(
itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile(
entry: _entries[i],
onCopy: () => _copyEntry(_entries[i], ctx),
),
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
),
);
}
}
class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry, required this.onCopy});
const _SyncLogTile({required this.entry});
final SyncLogEntry entry;
final VoidCallback onCopy;
@override
Widget build(BuildContext context) {
@@ -209,12 +115,6 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
final subtitleText = entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: entry.isPermanent
? 'Error (permanent) · took $durationLabel'
: 'Error · took $durationLabel';
return ExpansionTile(
leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -225,20 +125,11 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor),
),
subtitle: Text(
subtitleText,
entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy as markdown',
onPressed: onCopy,
),
const Icon(Icons.expand_more),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -280,31 +171,6 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12),
),
),
if (entry.stackTrace != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
child: Text(
'Stack trace',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.stackTrace!,
style: TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: Colors.red[300],
),
),
),
],
if (entry.protocolLog != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
+7 -75
View File
@@ -7,7 +7,6 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -29,16 +28,9 @@ class ThreadDetailScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold(
appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
appBar: AppBar(title: const Text('Thread')),
body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) {
@@ -68,20 +60,6 @@ class ThreadDetailScreen extends ConsumerWidget {
),
);
}
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
}
class _EmailMessageCard extends ConsumerStatefulWidget {
@@ -113,14 +91,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 +125,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 +141,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 +151,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(
+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.'),
-179
View File
@@ -1,179 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
const UserPreferencesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
body: prefsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading preferences')),
data: (prefs) => ListView(
children: [
ListTile(
title: Text(
'Menu bar position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the folder navigation menu is shown in the mailbox view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.menuPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMenuPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Open folder navigation from a button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Open folder navigation from the hamburger icon in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text('Show the back button in the top bar.'),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text('Show the next message in the mailbox.'),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text('Return to the message list.'),
value: AfterMailViewAction.showMailbox,
),
],
),
),
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),
);
},
),
),
],
),
],
),
),
);
}
}
-76
View File
@@ -1,76 +0,0 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
String buildAboutMarkdown({
required BuildContext context,
PackageInfo? pkg,
required int imapCount,
required int jmapCount,
String? deviceModel,
}) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString();
final textScale = MediaQuery.of(
context,
).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceModelLine =
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$deviceModelLine'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| Locale | $locale |\n'
'| Text Scale | $textScale× |\n'
'| DB Schema Version | $dbSchemaVersion |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
/// Fetches device model string, or null when unavailable.
Future<String?> getDeviceModel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.manufacturer} / ${android.model}';
} else if (Platform.isIOS) {
final ios = await info.iosInfo;
return ios.utsname.machine;
}
} catch (_) {}
return null;
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
-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;
}
+13 -22
View File
@@ -31,13 +31,10 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp">
<style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; }
a { color: #1976D2; }
* { box-sizing: border-box; max-width: 100%; }
table { width: 100%; border-collapse: collapse; }
td, th { overflow-wrap: break-word; word-break: break-word; }
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
* { box-sizing: border-box; }
</style>
</head>
<body>
@@ -111,16 +108,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 +184,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')),
);
}
}
}
+53 -93
View File
@@ -249,22 +249,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
drift:
dependency: "direct main"
description:
@@ -329,14 +313,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -349,10 +325,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "12.0.0-beta.4"
version: "8.3.7"
fixnum:
dependency: transitive
description:
@@ -375,42 +351,34 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "4.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "21.0.0"
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "8.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
@@ -431,34 +399,34 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
version: "10.2.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
version: "0.3.1"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@@ -479,10 +447,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -518,10 +486,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "17.2.3"
version: "14.8.1"
graphs:
dependency: transitive
description:
@@ -619,10 +587,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "4.0.0"
logging:
dependency: transitive
description:
@@ -659,10 +627,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:
@@ -675,10 +643,10 @@ packages:
dependency: "direct main"
description:
name: mobile_scanner
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
url: "https://pub.dev"
source: hosted
version: "7.2.0"
version: "5.2.3"
mockito:
dependency: "direct dev"
description:
@@ -731,18 +699,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "10.1.0"
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "3.2.1"
path:
dependency: "direct main"
description:
@@ -907,26 +875,26 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "2.6.1"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
version: "12.0.2"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
version: "6.1.0"
shelf:
dependency: transitive
description:
@@ -1008,10 +976,10 @@ packages:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.6.0+eol"
version: "0.5.42"
sqlparser:
dependency: transitive
description:
@@ -1088,34 +1056,34 @@ 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:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.11.0"
version: "0.10.1"
typed_data:
dependency: transitive
description:
@@ -1133,13 +1101,13 @@ packages:
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: "direct overridden"
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.24"
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
@@ -1296,18 +1264,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "6.3.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "5.15.0"
workmanager:
dependency: "direct main"
description:
+10 -15
View File
@@ -19,24 +19,24 @@ dependencies:
# Local persistence (offline-first)
drift: ^2.20.3
sqlite3_flutter_libs: ^0.6.0+eol
sqlite3_flutter_libs: ^0.5.28
path_provider: ^2.1.5
path: ^1.9.1
# State management
flutter_riverpod: ^3.0.0
flutter_riverpod: ^2.6.1
# Navigation
go_router: ^17.2.3
go_router: ^14.8.1
# Secure credential storage (passwords)
flutter_secure_storage: ^10.0.0
# Date formatting
intl: ^0.20.2
intl: any
# File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4
file_picker: ^8.0.0
open_filex: ^4.6.0
mime: ^2.0.0
@@ -47,7 +47,7 @@ dependencies:
cryptography: ^2.7.0
# QR code scanning (camera) for secure account import
mobile_scanner: ^7.2.0
mobile_scanner: ^5.0.0
# HTML rendering for email bodies
webview_flutter: ^4.0.0
@@ -55,20 +55,19 @@ dependencies:
flutter_markdown_plus: ^1.0.7
# Background sync and local notifications
flutter_local_notifications: ^21.0.0
flutter_local_notifications: ^18.0.1
workmanager: ^0.9.0
# App version metadata for crash reports
package_info_plus: ^10.1.0
share_plus: ^13.1.0
device_info_plus: ^13.1.0
package_info_plus: ^8.0.0
share_plus: ^12.0.2
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_lints: ^4.0.0
drift_dev: ^2.20.3
build_runner: ^2.4.13
test: ^1.25.0
@@ -90,7 +89,3 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21"
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
# channel-error on launchUrl on some Android devices (same root cause as
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
url_launcher_android: ">=6.3.0 <6.3.25"
-39
View File
@@ -1,39 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"labels": ["dependencies"],
"github-actions": {
"enabled": false
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
},
{
"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>.*)$"
}
]
}
+585
View File
@@ -0,0 +1,585 @@
#!/usr/bin/env python3
"""
agent_loop.py called from cron every 10 minutes.
Flow
----
1. Agent already running?
a. Age > 1 h kill it, set its issue to State/Question, exit 1
b. Age 1 h print status, exit 0 (let it keep working)
2. No agent running extract pending_issue from state (if any), then check CI
a. CI is running save pending-ci state, exit 0
b. Latest CI failed start fix-CI agent (preserving pending_issue), exit 0
c. CI ok + pending_issue close the issue (CI passed), exit 0
d. CI ok (or no run yet) find oldest Ready issue, start issue agent,
save state, exit 0
e. No Ready issues print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
Resume the Claude conversation afterward with:
claude --resume issue-91
"""
import argparse
import json
import os
import shlex
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
os.environ["PATH"] = (
f"{Path.home()}/.nix-profile/bin"
f":{Path.home()}/go/bin"
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
)
# ── configuration ─────────────────────────────────────────────────────────────
REPO = "guettli/sharedinbox"
REPO_URL = f"https://codeberg.org/{REPO}"
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
"-" + str(Path.home())[1:].replace("/", "-")
)
# Labels used by the workflow.
LABEL_READY = "State/Ready"
LABEL_IN_PROGRESS = "State/InProgress"
LABEL_QUESTION = "State/Question"
LABEL_PRIO_HIGH = "Prio/High"
# Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
# ── helpers ───────────────────────────────────────────────────────────────────
def _issue_url(number: int) -> str:
return f"{REPO_URL}/issues/{number}"
def _ci_run_url(run_id: int) -> str:
return f"{REPO_URL}/actions/runs/{run_id}"
def _fgj(*args: str) -> None:
"""Run a fgj command, raising on failure."""
cmd = ["fgj", "--hostname", "codeberg.org", *args]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"fgj {' '.join(args)} failed:\n{result.stderr or result.stdout}"
)
def _tea_get(path: str) -> dict | list | None:
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
cmd = ["tea", "api", path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"tea api {path} failed:\n{result.stderr or result.stdout}"
)
out = result.stdout.strip()
if not out:
return None
data = json.loads(out)
if isinstance(data, dict) and "message" in data and "url" in data:
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
return data
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
"""Add/remove labels on an issue via fgj."""
cmd = ["issue", "edit", str(issue), "--repo", REPO]
for label in add:
cmd += ["--add-label", label]
for label in remove:
cmd += ["--remove-label", label]
_fgj(*cmd)
def _close_issue(issue: int) -> None:
_fgj("issue", "close", str(issue), "--repo", REPO)
_set_labels(issue, add=[], remove=[LABEL_IN_PROGRESS])
def _comment_issue(issue: int, body: str) -> None:
_fgj("issue", "comment", str(issue), "--repo", REPO, "--body", body)
def _ready_issues() -> list[dict]:
"""Return open issues with State/Ready, Prio/High first, then oldest."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "issue", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout) if result.stdout.strip() else []
ready = [
i for i in data
if any(lbl["name"] == LABEL_READY for lbl in i.get("labels", []))
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
]
ready.sort(key=lambda i: (
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
i["number"],
))
return ready
def _latest_ci_run() -> dict | None:
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
runs = (data or {}).get("workflow_runs", [])
return runs[0] if runs else None
def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None."""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if run.get("head_branch") == branch:
return run
return None
def _find_pr_for_branch(branch: str) -> dict | None:
"""Return the first open PR whose head branch matches, or None."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return None
prs = json.loads(result.stdout)
for pr in prs:
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if ref == branch:
return pr
return None
def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
# ── state file ────────────────────────────────────────────────────────────────
def _read_state() -> dict | None:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return None
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
data: dict = {
"pid": pid,
"issue": issue,
"started_at": datetime.now(timezone.utc).isoformat(),
"type": kind,
}
if issue_title is not None:
data["issue_title"] = issue_title
if session_name is not None:
data["session_name"] = session_name
if ci_run_id is not None:
data["ci_run_id_at_start"] = ci_run_id
STATE_FILE.write_text(json.dumps(data, indent=2))
STATE_FILE.chmod(0o600)
def _clear_state() -> None:
STATE_FILE.unlink(missing_ok=True)
# ── agent launcher ────────────────────────────────────────────────────────────
def _start_agent(prompt: str, session_name: str) -> int:
"""Start Claude Code as a detached background process and return its PID."""
log_dir = Path.home() / ".sharedinbox-agent-logs"
log_dir.mkdir(mode=0o700, exist_ok=True)
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
log_file = log_dir / f"{session_name}-{ts}.log"
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
proc = subprocess.Popen(
[
"claude",
"--dangerously-skip-permissions",
"--name", session_name,
"-p", prompt,
],
stdin=subprocess.PIPE,
stdout=log_fh,
stderr=log_fh,
start_new_session=True,
)
log_fh.close() # Parent closes its copy; the child retains the fd.
# Answer the workspace-trust dialog; after this the pipe hits EOF.
proc.stdin.write(b"\n")
proc.stdin.close()
print(f"Started agent pid={proc.pid}, log={log_file}")
print(f" Resume: claude --resume {shlex.quote(session_name)}")
return proc.pid
def _agent_alive(state: dict) -> bool:
"""Return True if the agent process is still running."""
pid = state.get("pid")
if pid is None:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _agent_age_seconds(state: dict) -> float:
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
try:
started_at = datetime.fromisoformat(state["started_at"])
return (datetime.now(timezone.utc) - started_at).total_seconds()
except Exception:
return 0.0
def _git_summary() -> str:
"""Return a one-line summary of the latest commit and whether it's been pushed."""
try:
commit = subprocess.run(
["git", "log", "--oneline", "-1"],
capture_output=True, text=True, check=True,
).stdout.strip()
ahead = subprocess.run(
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
capture_output=True, text=True,
)
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
elif ahead.returncode == 0:
push_status = "pushed"
else:
push_status = "no upstream"
return f"{commit} [{push_status}]"
except Exception:
return ""
def _kill_agent(state: dict) -> None:
"""Forcefully stop the running agent."""
pid = state.get("pid")
if pid:
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
# ── subcommands ───────────────────────────────────────────────────────────────
def cmd_list() -> int:
"""List recent agent-loop sessions, newest first."""
if not CLAUDE_PROJECTS_DIR.exists():
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
return 0
sessions = []
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
agent_name = None
session_id = None
try:
with jsonl.open() as fh:
for line in fh:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("type") == "agent-name":
agent_name = d.get("agentName")
session_id = d.get("sessionId")
break
except Exception:
continue
if agent_name:
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
if not sessions:
print("No agent sessions found.")
return 0
sessions.sort(reverse=True)
total = len(sessions)
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
print(f" {'-'*16} {'-'*20} {'-'*36}")
for mtime, name, sid in sessions[:20]:
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
print(f" {ts:<16} {name:<20} {sid}")
if total > 20:
print(f" ... ({total - 20} more)")
return 0
# ── main flow ─────────────────────────────────────────────────────────────────
def _run_loop() -> int:
now = datetime.now(timezone.utc)
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
state = _read_state()
# ── 1. Agent already running? ─────────────────────────────────────────────
if state and _agent_alive(state):
age = _agent_age_seconds(state)
issue = state.get("issue")
kind = state.get("type", "issue")
pid = state.get("pid", "?")
issue_title = state.get("issue_title", "")
issue_ref = (
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
)
if age > MAX_AGENT_AGE_SECONDS:
print(
f"Agent pid={pid!r} ({issue_ref}) "
f"has been running for {age/60:.0f} min — aborting."
)
_kill_agent(state)
_clear_state()
if issue:
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue,
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
"Please investigate and resume manually.",
)
print(f"Set {_issue_url(issue)} to State/Question.")
return 1
session_name = state.get("session_name")
resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else ""
git_info = _git_summary()
parts = [
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
]
if resume_cmd:
parts.append(f" Resume: {resume_cmd}")
if git_info:
parts.append(f" Commit: {git_info}")
print("\n".join(parts))
return 0
# Agent not running (or no state) — extract any pending issue, then clean up.
pending_issue: int | None = None
ci_run_id_at_start: int | None = None
if state:
pending_issue = state.get("issue")
ci_run_id_at_start = state.get("ci_run_id_at_start")
_clear_state()
# ── 2. Check for a PR opened by the agent ────────────────────────────────
if pending_issue:
branch = f"issue-{pending_issue}-fix"
pr = _find_pr_for_branch(branch)
if pr:
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
pr_run = _latest_ci_run_for_branch(branch)
if pr_run and pr_run.get("status") == "running":
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
_write_state(None, pending_issue, "pending-ci")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
prompt = (
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
f"(PR #{pr_number}). "
f"CI run: {_ci_run_url(pr_run['id'])}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push to the same branch. "
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
"Verify locally with 'task check' before pushing. "
"When done, stop."
)
session_name = f"ci-fix-pr-{pr_number}"
pid = _start_agent(prompt, session_name)
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
return 0
if not pr_run:
# No CI run yet — might be that CI hasn't triggered yet.
# Wait up to 15 min before giving up.
pr_created_at = pr.get("created_at", "")
try:
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
age_s = (datetime.now(timezone.utc) - created).total_seconds()
except Exception:
age_s = 999999
if age_s < 900:
print(
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
)
_write_state(None, pending_issue, "pending-ci")
return 0
print(
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
"agent may not have pushed. Setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
"Please investigate and resume manually.",
)
return 0
# CI passed on the PR branch — squash-merge and close.
print(f"CI passed on branch {branch!r} — merging PR #{pr_number}.")
_merge_pr(pr_number)
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
run = _latest_ci_run()
if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
if pending_issue:
_write_state(None, pending_issue, "pending-ci")
return 0
if run and run.get("status") in ("failure", "error"):
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
prompt = (
"The Codeberg CI for guettli/sharedinbox just failed. "
f"The CI run ID is {run['id']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push. "
"Verify locally with 'task check' before pushing. "
"When done, stop."
)
pid = _start_agent(prompt, "ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
return 0
# CI is ok (or no run).
if pending_issue:
latest_run_id = run["id"] if run else None
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
# CI run hasn't changed since the agent was launched → agent pushed nothing
# (likely crashed or hit a rate limit).
print(
f"No new CI run since agent started for {_issue_url(pending_issue)} "
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
"The agent exited without pushing any changes (no new CI run was triggered). "
"This usually means the agent hit a rate limit or crashed at startup. "
"The issue has been set to State/Question — please review the agent log and retry.",
)
return 0
_close_issue(pending_issue)
print(f"CI passed — closed {_issue_url(pending_issue)}.")
return 0
# Find a Ready issue.
issues = _ready_issues()
if not issues:
print("No issues with State/Ready. Nothing to do.")
return 0
issue = issues[0]
issue_number = issue["number"]
issue_title = issue["title"]
issue_body = issue.get("body", "")
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
# Mark InProgress before starting so the next cron tick sees it even if
# the agent hasn't had time to do so yet.
_set_labels(
issue_number,
add=[LABEL_IN_PROGRESS],
remove=[LABEL_READY],
)
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
Issue title: {issue_title}
Issue body:
{issue_body}
Instructions:
- Understand the issue thoroughly before writing any code.
- Implement the required change, following the existing code style.
- Write or update tests as appropriate.
- Run 'task check' locally and fix any failures before committing.
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
git checkout -b issue-{issue_number}-fix
git push -u origin issue-{issue_number}-fix
fgj pr create --title "fix: <short description> (#{issue_number})" \\
--head issue-{issue_number}-fix --base main --repo {REPO}
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR the loop handles that after CI passes.
- If you hit a blocker you cannot resolve, set the issue label to State/Question
and stop (do NOT close the issue).
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
"""
session_name = f"issue-{issue_number}"
pid = _start_agent(prompt, session_name)
current_run_id = run["id"] if run else None
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="agent_loop")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("list", help="List recent agent sessions")
args = parser.parse_args()
if args.cmd == "list":
return cmd_list()
return _run_loop()
if __name__ == "__main__":
sys.exit(main())
-8
View File
@@ -11,7 +11,6 @@ const _minCoveragePercent = 80;
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
const _noCode = {
'lib/core/db_schema_version.dart',
'lib/core/repositories/account_repository.dart',
'lib/core/repositories/draft_repository.dart',
'lib/core/repositories/email_repository.dart',
@@ -20,9 +19,7 @@ const _noCode = {
'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart',
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -60,9 +57,6 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart',
'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',
@@ -76,8 +70,6 @@ const _excluded = {
'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart',
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart',
};
+67 -60
View File
@@ -6,49 +6,76 @@ import os
import sys
import time
import requests
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_MAX_UPLOAD_ATTEMPTS = 3
def _upload_aab_resumable(session, package, edit_id, aab_path):
"""Upload AAB using the Google resumable upload protocol."""
file_size = os.path.getsize(aab_path)
init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
# Step 1: initiate the resumable upload session
init_resp = session.post(
init_url,
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
"Content-Length": "0",
},
timeout=60,
def _make_session(config_json: str) -> AuthorizedSession:
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
return AuthorizedSession(creds)
# Step 2: upload the file in a single PUT to the session URI
with open(aab_path, "rb") as f:
upload_resp = session.put(
upload_url,
data=f,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=600,
)
upload_resp.raise_for_status()
return upload_resp.json()
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
"""Resumable upload of the AAB. Returns the version code."""
file_size = os.path.getsize(AAB_PATH)
with open(AAB_PATH, "rb") as f:
data = f.read()
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
init_resp = session.post(
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
},
json={},
timeout=30,
)
if not init_resp.ok:
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
upload_resp = session.put(
upload_url,
data=data,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=_TIMEOUT,
)
if not upload_resp.ok:
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
upload_resp.raise_for_status()
return upload_resp.json()["versionCode"]
except requests.RequestException as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
time.sleep(delay)
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
def main():
@@ -61,45 +88,25 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
sys.exit(1)
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
session = _make_session(config_json)
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
last_exc = None
bundle = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
break
except Exception as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
f"retrying in {delay}s…"
)
time.sleep(delay)
if bundle is None:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = bundle["versionCode"]
version_code = _upload_aab(session, edit_id)
print(f"Uploaded AAB, version code: {version_code}")
track_resp = session.put(
tracks_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
tracks_resp.raise_for_status()
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
+3
View File
@@ -33,6 +33,9 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
result = subprocess.run(
[
"ssh",
"-v",
"-o", "StrictHostKeyChecking=no",
"-i", "/root/.ssh/id_ed25519",
f"{ssh_user}@{ssh_host}",
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
],
+58 -63
View File
@@ -1,79 +1,74 @@
#!/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"
echo "Probing $host:$port..."
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
echo "Error: No Dagger server responded on $host:$port"
exit 1
fi
echo "Found active Dagger server on $host:$port"
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
# 2. Setup TLS credentials (passed as env vars from secrets)
mkdir -p /tmp/dagger-tls
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
chmod 600 /tmp/dagger-tls/client.key
# 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"
fi
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
}
# 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
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"
[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
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
# Start stunnel in the background
stunnel "$STUNNEL_CONF" &
TUNNEL_PID=$!
# Add remote host to known_hosts
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Give it a moment to establish
sleep 2
# 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"
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
echo "Error: stunnel failed to start"
exit 1
fi
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
# 4. Export environment for subsequent CI steps
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
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
# 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
exit 1
fi
echo "Dagger connection verified successfully."
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""Tests for agent_loop.py."""
import contextlib
import io
import json
import os
import tempfile
import unittest
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 TestKillAgent(unittest.TestCase):
def test_kill_sends_sigkill(self):
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.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()
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._latest_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._latest_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._latest_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._latest_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 test_closes_issue_when_ci_passes_after_agent_finishes(self):
"""After issue agent finishes, loop closes the issue once CI is green."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
def test_does_not_close_issue_when_ci_fails(self):
"""After issue agent finishes, loop must NOT close the issue if CI failed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
patch("agent_loop._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 after agent finishes, pending issue is preserved."""
written = {}
def fake_write_state(pid, issue, kind, issue_title=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._latest_ci_run", return_value={"id": 1, "status": "running"}), \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
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 after agent finishes, ci-fix state includes the pending issue."""
written = {}
def fake_write_state(pid, issue, kind, issue_title=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._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
patch("agent_loop._start_agent", return_value=55), \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
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, the pending issue is closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._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_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._latest_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._latest_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._latest_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._latest_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._latest_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)
if __name__ == "__main__":
unittest.main()
-200
View File
@@ -1,200 +0,0 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
def _make_session(
edit_id="edit-42",
version_code=7,
upload_side_effects=None,
):
"""Return a mock AuthorizedSession with sensible defaults."""
session = MagicMock()
# POST /edits → create edit
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": edit_id}
session.post.return_value = edit_resp
# POST resumable-init → Location header
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/session"}
# PUT upload → bundle JSON
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": version_code}
if upload_side_effects is not None:
# Use side_effect list: first call is edit create, rest are upload inits
# We override the PUT side effects via _upload_aab_resumable mock instead
pass
return session, init_resp, upload_resp
class TestMainEnvChecks(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
def test_missing_aab_exits(self):
fake_config = '{"type": "service_account"}'
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=False):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
class TestMainHappyPath(unittest.TestCase):
def _run_main(self, fake_config='{"type":"service_account"}'):
mock_session = MagicMock()
# POST for edit create and commit
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
MagicMock(), # commit
]
mock_session.post.side_effect = post_responses
# PUT for track update
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
return_value={"versionCode": 7},
):
deploy_playstore.main()
return mock_session
def test_creates_edit(self):
session = self._run_main()
create_call = session.post.call_args_list[0]
self.assertIn("/edits", create_call[0][0])
def test_commits_edit(self):
session = self._run_main()
commit_call = session.post.call_args_list[1]
self.assertIn(":commit", commit_call[0][0])
def test_updates_track(self):
session = self._run_main()
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):
mock_session = MagicMock()
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
MagicMock(),
]
mock_session.post.side_effect = post_responses
mock_session.put.return_value = MagicMock()
patches = [
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
patch("deploy_playstore.os.path.exists", return_value=True),
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
patch("deploy_playstore.time.sleep"),
]
for p in patches:
p.start()
try:
deploy_playstore.main()
finally:
for p in patches:
p.stop()
def test_succeeds_on_first_attempt(self):
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
deploy_playstore.main()
mock_upload.assert_called_once()
def test_retries_once_on_error_then_succeeds(self):
self._run_main([ValueError("transient"), {"versionCode": 9}])
def test_raises_after_all_attempts_exhausted(self):
with self.assertRaises(RuntimeError) as ctx:
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
def test_backoff_delays_are_10s_then_20s(self):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
class TestUploadAabResumable(unittest.TestCase):
def test_initiates_and_uploads(self):
mock_session = MagicMock()
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/sess"}
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": 42}
mock_session.post.return_value = init_resp
mock_session.put.return_value = upload_resp
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"fake-aab-content")
aab_path = f.name
try:
result = deploy_playstore._upload_aab_resumable(
mock_session, "com.example.app", "edit-1", aab_path
)
finally:
os.unlink(aab_path)
self.assertEqual(result["versionCode"], 42)
mock_session.post.assert_called_once()
mock_session.put.assert_called_once()
put_call = mock_session.put.call_args
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
if __name__ == "__main__":
unittest.main()
-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
+49 -71
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(
@@ -153,29 +149,17 @@ class _FakeMailboxes implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
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 +170,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([]);
@@ -308,8 +288,6 @@ class _FakeLogs implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
+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));
});
}
@@ -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();
+5 -89
View File
@@ -1,8 +1,6 @@
import 'dart:async';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:mockito/annotations.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -32,46 +30,15 @@ void main() {
// This is hard to test without real loops, but we can verify it doesn't crash.
manager.syncNow('unknown');
});
// Regression test for issue #200: when flutter_secure_storage throws
// MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff.
test(
'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async {
final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
syncLog: syncLog,
);
m.start();
// Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1));
m.dispose();
},
);
}
class FakeEmailRepository implements EmailRepository {
@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 +48,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
@@ -181,8 +145,6 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -224,50 +186,4 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
static const _account = Account(
id: '1',
displayName: 'Test',
email: 'test@example.com',
);
@override
Stream<List<Account>> observeAccounts() => Stream.value([_account]);
@override
Future<Account?> getAccount(String id) async => _account;
@override
Future<String> getPassword(String accountId) => Future.error(
MissingPluginException(
'No implementation found for method read on channel '
'plugins.it.nomads.com/flutter_secure_storage',
),
);
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
}
+157 -208
View File
@@ -3,16 +3,16 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i5;
import 'dart:async' as _i4;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
import 'package:mockito/src/dummies.dart' as _i6;
import 'package:sharedinbox/core/models/account.dart' as _i5;
import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
_FakeMailbox_0(
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
_FakeEmailBody_0(
Object parent,
Invocation parentInvocation,
) : super(
@@ -39,8 +39,9 @@ class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
);
}
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
_FakeEmailBody_1(
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
implements _i2.SyncEmailsResult {
_FakeSyncEmailsResult_1(
Object parent,
Invocation parentInvocation,
) : super(
@@ -49,20 +50,9 @@ class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
);
}
class _FakeSyncEmailsResult_2 extends _i1.SmartFake
implements _i3.SyncEmailsResult {
_FakeSyncEmailsResult_2(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeReliabilityResult_3 extends _i1.SmartFake
implements _i3.ReliabilityResult {
_FakeReliabilityResult_3(
class _FakeReliabilityResult_2 extends _i1.SmartFake
implements _i2.ReliabilityResult {
_FakeReliabilityResult_2(
Object parent,
Invocation parentInvocation,
) : super(
@@ -74,32 +64,32 @@ class _FakeReliabilityResult_3 extends _i1.SmartFake
/// A class which mocks [AccountRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
MockAccountRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
Invocation.method(
#observeAccounts,
[],
),
returnValue: _i5.Stream<List<_i6.Account>>.empty(),
) as _i5.Stream<List<_i6.Account>>);
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
) as _i4.Stream<List<_i5.Account>>);
@override
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
Invocation.method(
#getAccount,
[id],
),
returnValue: _i5.Future<_i6.Account?>.value(),
) as _i5.Future<_i6.Account?>);
returnValue: _i4.Future<_i5.Account?>.value(),
) as _i4.Future<_i5.Account?>);
@override
_i5.Future<void> addAccount(
_i6.Account? account,
_i4.Future<void> addAccount(
_i5.Account? account,
String? password,
) =>
(super.noSuchMethod(
@@ -110,13 +100,13 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
password,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> updateAccount(
_i6.Account? account, {
_i4.Future<void> updateAccount(
_i5.Account? account, {
String? password,
}) =>
(super.noSuchMethod(
@@ -125,65 +115,65 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
[account],
{#password: password},
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
Invocation.method(
#removeAccount,
[id],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
Invocation.method(
#getPassword,
[accountId],
),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this,
Invocation.method(
#getPassword,
[accountId],
),
)),
) as _i5.Future<String>);
) as _i4.Future<String>);
}
/// A class which mocks [MailboxRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
MockMailboxRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeMailboxes,
[accountId],
),
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
) as _i5.Stream<List<_i2.Mailbox>>);
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
) as _i4.Stream<List<_i8.Mailbox>>);
@override
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
Invocation.method(
#syncMailboxes,
[accountId],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Future<_i2.Mailbox?> findMailboxByRole(
_i4.Future<_i8.Mailbox?> findMailboxByRole(
String? accountId,
String? role,
) =>
@@ -195,46 +185,18 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
role,
],
),
returnValue: _i5.Future<_i2.Mailbox?>.value(),
) as _i5.Future<_i2.Mailbox?>);
returnValue: _i4.Future<_i8.Mailbox?>.value(),
) as _i4.Future<_i8.Mailbox?>);
@override
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<_i2.Mailbox> createMailboxWithRole(
String? accountId,
String? name,
String? role,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
/// A class which mocks [EmailRepository].
@@ -246,13 +208,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
}
@override
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued),
returnValue: _i5.Stream<String>.empty(),
) as _i5.Stream<String>);
returnValue: _i4.Stream<String>.empty(),
) as _i4.Stream<String>);
@override
_i5.Stream<List<_i3.Email>> observeEmails(
_i4.Stream<List<_i2.Email>> observeEmails(
String? accountId,
String? mailboxPath, {
int? limit = 50,
@@ -266,11 +228,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
{#limit: limit},
),
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>);
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@override
_i5.Stream<List<_i3.EmailThread>> observeThreads(
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId,
String? mailboxPath, {
int? limit = 50,
@@ -284,24 +246,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
{#limit: limit},
),
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>);
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.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(
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
String? accountId,
String? mailboxPath,
String? threadId,
@@ -315,36 +264,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
threadId,
],
),
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>);
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@override
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#getEmail,
[emailId],
),
returnValue: _i5.Future<_i3.Email?>.value(),
) as _i5.Future<_i3.Email?>);
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
@override
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod(
Invocation.method(
#getEmailBody,
[emailId],
),
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
this,
Invocation.method(
#getEmailBody,
[emailId],
),
)),
) as _i5.Future<_i3.EmailBody>);
) as _i4.Future<_i2.EmailBody>);
@override
_i5.Future<_i3.SyncEmailsResult> syncEmails(
_i4.Future<_i2.SyncEmailsResult> syncEmails(
String? accountId,
String? mailboxPath,
) =>
@@ -357,7 +306,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
returnValue:
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
this,
Invocation.method(
#syncEmails,
@@ -367,10 +316,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i5.Future<_i3.SyncEmailsResult>);
) as _i4.Future<_i2.SyncEmailsResult>);
@override
_i5.Future<void> setFlag(
_i4.Future<void> setFlag(
String? emailId, {
bool? seen,
bool? flagged,
@@ -384,12 +333,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
#flagged: flagged,
},
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> markAllAsRead(
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
@@ -401,12 +350,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
mailboxPath,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> moveEmail(
_i4.Future<void> moveEmail(
String? emailId,
String? destMailboxPath,
) =>
@@ -418,23 +367,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
destMailboxPath,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#deleteEmail,
[emailId],
),
returnValue: _i5.Future<String?>.value(),
) as _i5.Future<String?>);
returnValue: _i4.Future<String?>.value(),
) as _i4.Future<String?>);
@override
_i5.Future<void> sendEmail(
_i4.Future<void> sendEmail(
String? accountId,
_i3.EmailDraft? draft,
_i2.EmailDraft? draft,
) =>
(super.noSuchMethod(
Invocation.method(
@@ -444,14 +393,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
draft,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<String> downloadAttachment(
_i4.Future<String> downloadAttachment(
String? emailId,
_i3.EmailAttachment? attachment,
_i2.EmailAttachment? attachment,
) =>
(super.noSuchMethod(
Invocation.method(
@@ -461,7 +410,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
attachment,
],
),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this,
Invocation.method(
#downloadAttachment,
@@ -471,25 +420,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i5.Future<String>);
) as _i4.Future<String>);
@override
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
Invocation.method(
#fetchRawRfc822,
[emailId],
),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this,
Invocation.method(
#fetchRawRfc822,
[emailId],
),
)),
) as _i5.Future<String>);
) as _i4.Future<String>);
@override
_i5.Future<List<_i3.Email>> searchEmails(
_i4.Future<List<_i2.Email>> searchEmails(
String? accountId,
String? mailboxPath,
String? query,
@@ -503,11 +452,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i5.Future<List<_i3.Email>> searchEmailsGlobal(
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
String? accountId,
String? query,
) =>
@@ -519,11 +468,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i5.Future<List<_i3.Email>> getEmailsByAddress(
_i4.Future<List<_i2.Email>> getEmailsByAddress(
String? accountId,
String? address,
) =>
@@ -535,11 +484,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
address,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i5.Future<List<_i3.EmailAddress>> searchAddresses(
_i4.Future<List<_i2.EmailAddress>> searchAddresses(
String? accountId,
String? query, {
int? limit = 10,
@@ -554,11 +503,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
{#limit: limit},
),
returnValue:
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
) as _i5.Future<List<_i3.EmailAddress>>);
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
) as _i4.Future<List<_i2.EmailAddress>>);
@override
_i5.Future<int> flushPendingChanges(
_i4.Future<int> flushPendingChanges(
String? accountId,
String? password,
) =>
@@ -570,42 +519,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password,
],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeFailedMutations,
[accountId],
),
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
) as _i5.Stream<List<_i3.FailedMutation>>);
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
) as _i4.Stream<List<_i2.FailedMutation>>);
@override
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#discardMutation,
[id],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#retryMutation,
[id],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<bool> cancelPendingChange(
_i4.Future<bool> cancelPendingChange(
String? emailId,
String? changeType,
) =>
@@ -617,11 +566,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
changeType,
],
),
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
returnValue: _i4.Future<bool>.value(false),
) as _i4.Future<bool>);
@override
_i5.Future<void> snoozeEmail(
_i4.Future<void> snoozeEmail(
String? emailId,
DateTime? until,
) =>
@@ -633,32 +582,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
until,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
Invocation.method(
#wakeUpEmails,
[accountId],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
(super.noSuchMethod(
Invocation.method(
#restoreEmails,
[emails],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<_i3.Email?> findEmailByMessageId(
_i4.Future<_i2.Email?> findEmailByMessageId(
String? accountId,
String? messageId,
) =>
@@ -670,20 +619,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
messageId,
],
),
returnValue: _i5.Future<_i3.Email?>.value(),
) as _i5.Future<_i3.Email?>);
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
@override
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
Invocation.method(
#applySieveRules,
[accountId],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Stream<void> watchJmapPush(
_i4.Stream<void> watchJmapPush(
String? accountId,
String? password,
) =>
@@ -695,11 +644,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password,
],
),
returnValue: _i5.Stream<void>.empty(),
) as _i5.Stream<void>);
returnValue: _i4.Stream<void>.empty(),
) as _i4.Stream<void>);
@override
_i5.Future<_i3.ReliabilityResult> verifySyncReliability(
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
String? accountId,
String? mailboxPath,
) =>
@@ -712,7 +661,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
returnValue:
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
this,
Invocation.method(
#verifySyncReliability,
@@ -722,15 +671,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i5.Future<_i3.ReliabilityResult>);
) as _i4.Future<_i2.ReliabilityResult>);
@override
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
+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));
-156
View File
@@ -1,156 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sharedinbox/data/db/database.dart';
// Fake PathProviderPlatform that always throws PlatformException(channel-error)
// to simulate the Pigeon channel not being ready at startup (issue #166).
class _UnavailablePathProvider extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
@override
Future<String?> getApplicationSupportPath() async {
throw PlatformException(
code: 'channel-error',
message: 'Simulated: path_provider channel not ready',
);
}
}
// Fake PathProviderPlatform that fails the first [failCount] calls, then
// returns a fixed path. Used to exercise the retry loop in
// _resolveDatabasePath() without waiting for real timers.
class _SucceedAfterNPathProvider extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
_SucceedAfterNPathProvider({required this.failCount});
final int failCount;
int _callCount = 0;
@override
Future<String?> getApplicationSupportPath() async {
_callCount++;
if (_callCount <= failCount) {
throw PlatformException(
code: 'channel-error',
message: 'Simulated: path_provider channel not ready',
);
}
return '/tmp/test_app_support';
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/166:
// On some slow Android devices the path_provider Pigeon channel is not ready
// when initDatabasePath() runs before runApp(). initDatabasePath() must
// absorb the PlatformException and let the app start; _resolveDatabasePath()
// then retries with back-off on first DB access.
test(
'initDatabasePath completes without throwing when path_provider is unavailable',
() async {
final prev = PathProviderPlatform.instance;
PathProviderPlatform.instance = _UnavailablePathProvider();
addTearDown(() => PathProviderPlatform.instance = prev);
// Must not throw — the exception is swallowed so the app can continue.
await expectLater(initDatabasePath(), completes);
},
);
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
// access when initDatabasePath() already failed. fake_async lets us advance
// the back-off timers without waiting real-world milliseconds.
test(
'_resolveDatabasePath retries and eventually succeeds after transient failures',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
// Fail 3 times, succeed on the 4th call. The delays in
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
String? result;
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
// Advance fake time through the three back-off delays.
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
expect(result, isNotNull);
expect(result, endsWith('sharedinbox.db'));
});
},
);
test(
'_resolveDatabasePath throws PlatformException after exhausting all retries',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
PathProviderPlatform.instance = _UnavailablePathProvider();
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
Object? caughtError;
unawaited(
resolveDatabasePathForTesting().catchError((Object e) {
caughtError = e;
return ''; // ignored; satisfies the Future<String> return type
}),
);
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
fake.elapse(
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
);
expect(caughtError, isA<PlatformException>());
expect(
(caughtError! as PlatformException).message,
contains('cannot open database'),
);
});
},
// The Android fallback runs only on Android, so on the host machine the
// exception is still thrown after all retries. Skip on Android to avoid
// depending on /data/user/0/... being absent in the test environment.
skip: Platform.isAndroid,
);
// Regression test for issue #192: _androidFallbackPath must return null when
// the process cmdline does not look like an Android package name (e.g. on
// the host test machine where the process is the Dart executable).
test(
'_androidFallbackPath returns null when process name is not a package name',
() async {
// On non-Android platforms the host process cmdline is a file-system path
// (starts with '/'), which the fallback correctly rejects. On Android
// the process IS named after the package — the fallback is free to
// succeed or return null depending on the device state; we do not assert
// here so as not to constrain Android behaviour.
if (!Platform.isAndroid) {
final result = await androidFallbackPathForTesting();
expect(result, isNull);
}
},
);
}
+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', () {

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