Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf59cf4621 |
@@ -1,18 +0,0 @@
|
||||
# Dagger context ignore file.
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.dart_tool/
|
||||
coverage/
|
||||
linux/flutter/ephemeral/
|
||||
website/public/
|
||||
website/resources/
|
||||
.task/
|
||||
.fvm/
|
||||
|
||||
# Secrets
|
||||
.env*
|
||||
.envrc
|
||||
@@ -14,7 +14,5 @@ PATH_add .fvm/flutter_sdk/bin
|
||||
|
||||
PATH_add "$HOME/Android/Sdk/platform-tools"
|
||||
|
||||
export DAGGER_NO_NAG=1
|
||||
|
||||
# Load variables from .env
|
||||
dotenv_if_exists
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Source: https://codeberg.org/guettli/sharedinbox/src/branch/main/.forgejo/Dockerfile
|
||||
# Install at on the act-runner host on: /etc/forgejo/runner/Dockerfile
|
||||
#
|
||||
# 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 \
|
||||
stunnel4 \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 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
|
||||
|
||||
# Task runner
|
||||
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v3.48.0
|
||||
+22
-91
@@ -3,109 +3,40 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'integration_test/**'
|
||||
- 'android/**'
|
||||
- 'linux/**'
|
||||
- 'assets/**'
|
||||
- '!assets/changelog.txt'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'scripts/**'
|
||||
- 'stalwart-dev/**'
|
||||
- 'ci/**'
|
||||
- 'Taskfile.yml'
|
||||
- 'drift_schemas/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'integration_test/**'
|
||||
- 'android/**'
|
||||
- 'linux/**'
|
||||
- 'assets/**'
|
||||
- '!assets/changelog.txt'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'scripts/**'
|
||||
- 'stalwart-dev/**'
|
||||
- 'ci/**'
|
||||
- 'Taskfile.yml'
|
||||
- 'drift_schemas/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Match the label of your self-hosted runner
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
- name: Enable Nix flakes
|
||||
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: Locate Docker daemon for local Dagger engine
|
||||
run: |
|
||||
# Skip if remote Dagger engine is already configured (preferred path)
|
||||
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
||||
echo "Remote Dagger engine configured, no local Docker needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try host Docker socket (DooD) if runner mounts it
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
||||
echo "Docker available via host socket."
|
||||
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
||||
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
||||
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
||||
echo "CI will likely fail at the Dagger step." >&2
|
||||
|
||||
- name: Prune Dagger cache before check
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
||||
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
||||
run: |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Run Full Check Suite
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task check-dagger
|
||||
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
|
||||
# 'task check' runs analyze, unit tests, widget tests, and integration tests.
|
||||
run: nix develop --command task check
|
||||
|
||||
- name: Prune Dagger cache after check
|
||||
if: always()
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
build-linux:
|
||||
name: Build Linux Release
|
||||
runs-on: self-hosted
|
||||
needs: check
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
- name: Build Linux
|
||||
run: nix develop --command task build-linux-release
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Detect Changed Files
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
android: ${{ steps.diff.outputs.android }}
|
||||
linux: ${{ steps.diff.outputs.linux }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
shell: bash
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
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
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Skip if this exact commit was already successfully deployed (prevents
|
||||
# hourly schedule from redeploying the same commit on every tick).
|
||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||
import json, os, sys, urllib.request
|
||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||
]
|
||||
print(runs[0]["head_sha"] if runs else "")
|
||||
except Exception as e:
|
||||
print(f"API check failed: {e}", file=sys.stderr)
|
||||
print("")
|
||||
PYEOF
|
||||
)
|
||||
|
||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||
|
||||
echo "$CHANGED" | grep -qE "$android_re" \
|
||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||
env:
|
||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task test-android-firebase
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-playstore:
|
||||
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
|
||||
|
||||
- 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: Publish Android to Play Store
|
||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-android
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-apk:
|
||||
name: Build & Deploy APK to Server
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
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; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task deploy-apk
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
build-linux:
|
||||
name: Build Linux Release
|
||||
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
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task deploy-linux
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||
if: |
|
||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||
)
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
issue = os.environ.get("DEPLOY_HEALTH_ISSUE", "").strip()
|
||||
if not issue:
|
||||
print("DEPLOY_HEALTH_ISSUE not set; skipping")
|
||||
raise SystemExit(0)
|
||||
|
||||
token = os.environ["FORGEJO_TOKEN"]
|
||||
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||
succeeded = os.environ.get("ALL_SUCCEEDED", "false").lower() == "true"
|
||||
add_label = "CI/Full-Pass" if succeeded else "CI/Full-Fail"
|
||||
remove_label = "CI/Full-Fail" if succeeded else "CI/Full-Pass"
|
||||
|
||||
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_put(path, body):
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="PUT")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"PUT {path} failed: {e.read().decode()}")
|
||||
raise
|
||||
|
||||
repo_labels = api_get("/labels")
|
||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||
|
||||
if add_label not in label_map:
|
||||
print(f"Label '{add_label}' not found in repo — create it first")
|
||||
raise SystemExit(1)
|
||||
|
||||
current = api_get(f"/issues/{issue}/labels")
|
||||
keep_ids = [l["id"] for l in current if l["name"] not in ("CI/Full-Pass", "CI/Full-Fail")]
|
||||
keep_ids.append(label_map[add_label])
|
||||
|
||||
api_put(f"/issues/{issue}/labels", {"labels": keep_ids})
|
||||
print(f"Set '{add_label}' on issue #{issue}")
|
||||
PYEOF
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Monitor Agent Loop
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # every 2 hours
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check Agent Loop Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check agent loop heartbeat
|
||||
run: python3 scripts/agent_loop.py monitor
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Install Android SDK (cached on runner between runs)
|
||||
run: |
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
if [ ! -d "$SDK/platforms/android-34" ]; then
|
||||
echo "Android SDK not found, installing..."
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
mkdir -p "$SDK/cmdline-tools"
|
||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
||||
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
||||
else
|
||||
echo "Android SDK cached, skipping install."
|
||||
fi
|
||||
|
||||
- name: Prepare Keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
||||
else
|
||||
echo "Error: ANDROID_KEYSTORE_BASE64 secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build & Deploy to Play Store
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||
run: nix develop --command task deploy-android-bundle
|
||||
@@ -1,39 +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; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
||||
run: task renovate
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
@@ -12,43 +12,36 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build & Deploy Website
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Check runner tools
|
||||
- name: Enable Nix flakes
|
||||
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; }
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
- name: Setup SSH
|
||||
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
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -n "$SSH_PRIVATE_KEY" ]; then
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
else
|
||||
echo "Error: WEBSITE_SSH_PRIVATE_KEY secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build & Deploy Website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Verify Website
|
||||
- name: Deploy
|
||||
env:
|
||||
SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: scripts/website-verify.sh
|
||||
run: nix develop --command task website-deploy
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
- name: Verify
|
||||
run: nix develop --command task website-verify
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Windows Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
windows-nightly:
|
||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||
name: Build & Deploy Windows (Nightly)
|
||||
runs-on: windows-runner
|
||||
if: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for recent changes on main
|
||||
run: |
|
||||
$changes = git log --oneline --since "24 hours ago" origin/main
|
||||
if (-not $changes) {
|
||||
Write-Output "No changes in last 24 hours, skipping build."
|
||||
Add-Content -Path $env:GITHUB_ENV -Value "SKIP_BUILD=true"
|
||||
}
|
||||
|
||||
- name: Build Windows
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: task build-windows-release
|
||||
|
||||
- name: Set up SSH key
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p $env:USERPROFILE\.ssh
|
||||
$env:SSH_PRIVATE_KEY | Out-File -FilePath "$env:USERPROFILE\.ssh\id_rsa" -Encoding ascii
|
||||
icacls "$env:USERPROFILE\.ssh\id_rsa" /inheritance:r /grant:r "$env:USERNAME:F"
|
||||
|
||||
- name: Deploy Windows to server
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: task deploy-windows-to-server
|
||||
+4
-101
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
analyze-and-test:
|
||||
name: Analyze & unit test
|
||||
runs-on: sharedinbox-runner
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
integration:
|
||||
name: Integration tests (Stalwart)
|
||||
runs-on: sharedinbox-runner
|
||||
runs-on: ubuntu-latest
|
||||
# Run integration tests only on push to main, not on every PR.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
integration-ui:
|
||||
name: UI Integration tests (Stalwart + Xvfb)
|
||||
runs-on: sharedinbox-runner
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
build-linux:
|
||||
name: Build Linux desktop
|
||||
runs-on: sharedinbox-runner
|
||||
runs-on: ubuntu-latest
|
||||
needs: analyze-and-test
|
||||
|
||||
steps:
|
||||
@@ -151,100 +151,3 @@ jobs:
|
||||
|
||||
- name: Build Linux release
|
||||
run: flutter build linux --release
|
||||
|
||||
deploy:
|
||||
name: Deploy Linux build & publish website
|
||||
runs-on: sharedinbox-runner
|
||||
needs: build-linux
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install build & deploy dependencies
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
||||
libsecret-1-dev hugo rsync
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.41.6"
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Cache pub packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate Drift code
|
||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
mkdir -p assets
|
||||
git log -n 50 \
|
||||
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
|
||||
--date=short > assets/changelog.txt
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Build Linux release
|
||||
run: |
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
|
||||
|
||||
- name: Deploy Linux build to server
|
||||
run: |
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
|
||||
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
WINDOWS_URL=$(echo "$EXISTING" | \
|
||||
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
||||
2>/dev/null || true)
|
||||
if [ -n "$WINDOWS_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
|
||||
- name: Generate build history pages
|
||||
run: python3 scripts/generate_build_history.py
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
|
||||
run: hugo --source website --minify
|
||||
|
||||
- name: Deploy website
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
website/public/ \
|
||||
"$SSH_USER@$SSH_HOST:public_html/"
|
||||
|
||||
+2
-18
@@ -3,6 +3,7 @@ coverage/
|
||||
.dart_tool/
|
||||
.dart-tool/
|
||||
.packages
|
||||
pubspec.lock
|
||||
build/
|
||||
*.g.dart
|
||||
*.freezed.dart
|
||||
@@ -28,8 +29,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/
|
||||
@@ -58,10 +58,6 @@ linux/flutter/generated_plugins.cmake
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# --- Tools & Cache ---
|
||||
.fvm/
|
||||
fvm/
|
||||
@@ -102,8 +98,6 @@ sharedinbox-runner/runner-data/
|
||||
website/public/
|
||||
website/resources/
|
||||
website/.hugo_build.lock
|
||||
website/content/builds/_index.md
|
||||
website/content/builds/[0-9]*/
|
||||
|
||||
.copilot/
|
||||
.dotnet/
|
||||
@@ -111,14 +105,4 @@ website/content/builds/[0-9]*/
|
||||
.wget-hsts
|
||||
|
||||
tmp/
|
||||
test/widget/failures/
|
||||
.claude*
|
||||
|
||||
dagger-certs
|
||||
.Xauthority
|
||||
.sharedinbox-agent-state.json
|
||||
|
||||
.viminfo
|
||||
/go
|
||||
.last_deployed_sha
|
||||
.fail_count
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "website/themes/PaperMod"]
|
||||
path = website/themes/PaperMod
|
||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
||||
@@ -12,12 +12,6 @@ repos:
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-no-binary
|
||||
name: check for binary files (build artifacts, databases)
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_no_binary.sh'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: forbidden-files-hook
|
||||
name: check for forbidden home-directory files
|
||||
language: system
|
||||
@@ -30,15 +24,3 @@ repos:
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: ci-no-direct-dagger
|
||||
name: check for direct dagger calls in workflows (use Task instead)
|
||||
language: system
|
||||
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 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
|
||||
|
||||
@@ -1,54 +1,5 @@
|
||||
# SharedInbox — Development Guide
|
||||
|
||||
## Codeberg
|
||||
|
||||
We use Codeberg: https://codeberg.org/guettli/sharedinbox/
|
||||
|
||||
CLI tool `fgj` is available to query issues/PRs/actions.
|
||||
|
||||
## Issue Label Workflow
|
||||
|
||||
We use issues, follow this label state machine:
|
||||
|
||||
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||
- **State/Ready** — Issue is approved and ready for implementation
|
||||
- **State/InProgress** — Set while an agent (or human) is actively working
|
||||
- **State/Question** — Agent hit a blocker or needs clarification
|
||||
|
||||
Full lifecycle:
|
||||
|
||||
```
|
||||
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||
any state → State/Question (automated or manual: when blocked)
|
||||
```
|
||||
|
||||
List open issues ready to pick up:
|
||||
|
||||
```bash
|
||||
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start implementation on an issue without `State/Ready`
|
||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
||||
- When working via the agent loop: label transitions are set automatically
|
||||
by `agent_loop.py` — do **not** set them yourself.
|
||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||
```bash
|
||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||
```
|
||||
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||
- When done and CI is green, close the issue:
|
||||
```bash
|
||||
fgj issue close <NUMBER>
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
- Avoid `else`, use "early return".
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# Dagger CI/CD Setup
|
||||
|
||||
This project has migrated from Taskfile-based CI to **Dagger**. This document explains the infrastructure setup for the shared Dagger Server.
|
||||
|
||||
## Architecture
|
||||
|
||||
We use a **Shared Dagger Server** approach for both local development and CI. This allows multiple users to share a single Dagger Engine and its cache, significantly speeding up builds.
|
||||
|
||||
- **Container Engine:** Rootless Podman (managed by the `dagger-svc` user).
|
||||
- **Orchestration:** System-wide `systemd` service.
|
||||
- **Access:** Users connect via TCP (localhost) or Unix Socket.
|
||||
|
||||
## Server Setup (Admin)
|
||||
|
||||
### 1. Dedicated Service User
|
||||
A dedicated user `dagger-svc` owns the Dagger Engine and its cache.
|
||||
|
||||
```bash
|
||||
sudo useradd -m -s /bin/bash dagger-svc
|
||||
sudo loginctl enable-linger dagger-svc
|
||||
```
|
||||
|
||||
**Why Lingering?**
|
||||
Lingering is required for rootless users to maintain a persistent background session. It ensures that `/run/user/<UID>` and the user-level Dagger/Podman namespaces are initialized at boot and remain active even when the user is not logged in.
|
||||
|
||||
### 2. Systemd Service
|
||||
The engine is managed by a system-wide systemd service located at `/etc/systemd/system/dagger-engine.service`.
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Dagger Engine (Shared Server)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dagger-svc
|
||||
Group=dagger-svc
|
||||
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.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
To connect to the shared engine, users should set the `_DAGGER_RUNNER_HOST` environment variable.
|
||||
|
||||
### Local Development (.env)
|
||||
The project uses a `.env` file to manage the connection string. Ensure your `.env` contains:
|
||||
|
||||
```bash
|
||||
_DAGGER_RUNNER_HOST=tcp://127.0.0.1:8080
|
||||
```
|
||||
|
||||
### Usage
|
||||
Once the environment is set up, you can run the Dagger pipeline. For non-interactive environments (CI, LLMs), use `--progress=plain` for readable logs:
|
||||
|
||||
```bash
|
||||
nix develop --command dagger call --progress=plain -q -m ci --source=. check
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
All sensitive credentials are passed as `dagger.Secret` (never as plain strings).
|
||||
This prevents values from appearing in Dagger logs or being cached in the engine.
|
||||
|
||||
| Parameter | Functions |
|
||||
|---|---|
|
||||
| `sshKey *dagger.Secret` | `Deployer`, `GenerateBuildHistory`, `BuildWebsite`, `PublishWebsite`, `DeployLinux`, `DeployApk` |
|
||||
| `keystoreBase64 *dagger.Secret` | `setupKeystore`, `BuildAndroidApk`, `DeployApk`, `SignAndroidBundle`, `PublishAndroid` |
|
||||
| `keystorePassword *dagger.Secret` | same as above |
|
||||
| `playStoreConfig *dagger.Secret` | `UploadToPlayStore`, `PublishAndroid` |
|
||||
| `serviceAccountKey *dagger.Secret` | `TestAndroidFirebase` |
|
||||
|
||||
Secrets are injected via `WithMountedSecret` (file-based, e.g. SSH key) or
|
||||
`WithSecretVariable` (env-var-based, e.g. keystore data, Play Store JSON).
|
||||
|
||||
The only credentials not typed as `dagger.Secret` are the test passwords
|
||||
(`STALWART_PASS_B`, `STALWART_PASS_C`) in `WithStalwart`. These are hardcoded
|
||||
development values defined in `stalwart-dev/` — not production secrets.
|
||||
|
||||
## CI Integration (Codeberg/Forgejo)
|
||||
|
||||
The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger module located in the `ci/` directory.
|
||||
|
||||
- **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.
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
# Development Environment Setup
|
||||
|
||||
This document explains how to set up a development environment for SharedInbox.
|
||||
|
||||
## ⚠️ Security Recommendation: Use a Dedicated Linux User
|
||||
|
||||
For enhanced security, especially when working with autonomous coding agents (like Gemini CLI in YOLO mode), we **strongly recommend** using a dedicated Linux user for this project. This isolates the project environment and prevents any potential accidental damage to your main system.
|
||||
|
||||
### 1. Create a Dedicated User
|
||||
|
||||
Set the user name variable (default is `si` for SharedInbox):
|
||||
|
||||
```bash
|
||||
export DEV_USER=si
|
||||
```
|
||||
|
||||
Create the user and add them to the `sudo` group:
|
||||
|
||||
```bash
|
||||
sudo adduser --disabled-password newuser $DEV_USER
|
||||
```
|
||||
|
||||
Set up SSH public key login (replace with your actual public key):
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /home/$DEV_USER/.ssh
|
||||
sudo chmod 700 /home/$DEV_USER/.ssh
|
||||
echo "ssh-ed25519 AAAA... your-key-comment" | sudo tee /home/$DEV_USER/.ssh/authorized_keys
|
||||
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
||||
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
||||
```
|
||||
|
||||
### 2. Switch to the Dedicated User
|
||||
|
||||
```bash
|
||||
ssh $DEV_USER@localhost
|
||||
```
|
||||
|
||||
### Create ssh-keypair
|
||||
|
||||
```bash
|
||||
ssh-keygen
|
||||
```
|
||||
|
||||
### 3. Clone the Repository
|
||||
|
||||
Clone the project into your new user's home directory:
|
||||
|
||||
```bash^
|
||||
git clone ssh://git@codeberg.org/guettli/sharedinbox.git
|
||||
|
||||
# Move git directory into $HOME
|
||||
# This user only works on the git repo. Avoid "cd sharedinbox" after each login...
|
||||
mv sharedinbox/* .
|
||||
mv sharedinbox/.??* .
|
||||
rmdir sharedinbox/
|
||||
```
|
||||
|
||||
### 3b. Configure Git Identity
|
||||
|
||||
The new user needs a Git identity for commits and some scripts:
|
||||
|
||||
```bash
|
||||
git config --global user.name "Your Name"
|
||||
git config --global user.email "your.email@example.com"
|
||||
```
|
||||
|
||||
### 4. Install System Dependencies
|
||||
|
||||
This project uses **Nix** with flakes to manage its toolchain (Flutter, Dart, Stalwart, etc.).
|
||||
|
||||
```
|
||||
mkdir -p .config/nix
|
||||
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
nix profile add nixpkgs#direnv
|
||||
|
||||
nix profile add nixpkgs#nix-direnv
|
||||
|
||||
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
.config/direnv/direnv.toml
|
||||
```
|
||||
[global]
|
||||
hide_env_diff = true
|
||||
#log_filter = "^$"
|
||||
|
||||
[whitelist]
|
||||
prefix = [ "/home/DEV_USER-CHANGE_THAT" ]
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 4b. Additional Permissions (GUI & Android)
|
||||
|
||||
1. **GUI Access**: To run the Linux app (`task run`) from the `si` user, you must allow it to access your X server. Run this **from your main user terminal**:
|
||||
```bash
|
||||
xhost +local:$DEV_USER
|
||||
```
|
||||
|
||||
2. **Android Emulator (KVM)**: If you plan to use the Android emulator, add the user to the `kvm` group:
|
||||
```bash
|
||||
sudo usermod -aG kvm $DEV_USER
|
||||
```
|
||||
|
||||
### 5. Project Setup
|
||||
|
||||
Once you are in the project directory and have the dependencies installed:
|
||||
|
||||
1. **Initialize Environment**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Allow direnv**:
|
||||
```bash
|
||||
direnv allow
|
||||
```
|
||||
*This will trigger Nix to download and set up the environment (Flutter, Android SDK, etc.). It might take some time on the first run.*
|
||||
|
||||
3. **Install Flutter (via FVM)**:
|
||||
Nix provides FVM, which manages the pinned Flutter version.
|
||||
```bash
|
||||
fvm install
|
||||
```
|
||||
|
||||
4. **Initial Setup**:
|
||||
Run the comprehensive setup command which handles `pub get`, code generation, and git hooks:
|
||||
```bash
|
||||
task setup
|
||||
```
|
||||
|
||||
### 6. Verify the Setup
|
||||
|
||||
Run the full check suite to ensure everything is working correctly:
|
||||
|
||||
```bash
|
||||
task check
|
||||
```
|
||||
|
||||
### 7. Running the App
|
||||
|
||||
To run the app on your Linux desktop:
|
||||
|
||||
```bash
|
||||
task run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Working with VS Code
|
||||
|
||||
To maintain isolation, it is recommended to run VS Code "remotely" on the dedicated development user.
|
||||
|
||||
### Preferred Method: VS Code Remote - SSH
|
||||
|
||||
The most robust way to work with a separate user is using the **VS Code Remote - SSH** extension. This allows you to run the VS Code Server as the `si` user while using your main user's GUI.
|
||||
|
||||
1. **Install the Extension**: Install "Remote - SSH" from the VS Code Marketplace.
|
||||
2. **Enable SSH for the Dev User**:
|
||||
From your main user, copy your SSH public key to the dev user:
|
||||
```bash
|
||||
# As your main user:
|
||||
sudo mkdir -p /home/$DEV_USER/.ssh
|
||||
sudo cp ~/.ssh/id_rsa.pub /home/$DEV_USER/.ssh/authorized_keys
|
||||
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
||||
sudo chmod 700 /home/$DEV_USER/.ssh
|
||||
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
||||
```
|
||||
3. **Connect**:
|
||||
In VS Code, open the Command Palette (`Ctrl+Shift+P`) and select `Remote-SSH: Connect to Host...`.
|
||||
Enter: `si@localhost` (or `$DEV_USER@localhost`).
|
||||
4. **Install Extensions in the Remote**:
|
||||
Once connected, you will need to install the following extensions *on the remote user*:
|
||||
* **Dart** / **Flutter**
|
||||
* **direnv**: (by mkhl) Highly recommended to automatically load the Nix environment inside VS Code.
|
||||
* **Nix IDE**: For syntax highlighting.
|
||||
|
||||
### Why SSH?
|
||||
Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks. It provides a clean boundary for the VS Code process and any integrated terminal or coding agents, ensuring they cannot access your personal files in `/home/$YOUR_USER`.
|
||||
|
||||
> **Note on Security:** While these instructions add the user to the `sudo` group for convenience during setup, you can remove it later with `sudo gpasswd -d $DEV_USER sudo` to further restrict the user and any coding agents.
|
||||
|
||||
---
|
||||
|
||||
## Daily Workflow
|
||||
|
||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||
@@ -1,206 +0,0 @@
|
||||
# Email Sync Architecture
|
||||
|
||||
This document describes the full lifecycle of an email action — from the moment the user taps
|
||||
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
|
||||
queue, exponential backoff, and the undo/cancel mechanism.
|
||||
|
||||
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Components
|
||||
|
||||
| Component | File | Role |
|
||||
|-----------|------|------|
|
||||
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
|
||||
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
|
||||
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
|
||||
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
|
||||
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
|
||||
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
|
||||
|
||||
---
|
||||
|
||||
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
|
||||
|
||||
```
|
||||
User taps "Mark as read"
|
||||
│
|
||||
▼
|
||||
EmailRepository.setFlag(id, seen: true)
|
||||
│
|
||||
├─ 1. Write optimistic update to local DB
|
||||
│ emails.is_seen = true
|
||||
│
|
||||
└─ 2. Insert row into pending_changes
|
||||
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
|
||||
(IMAP: includes uid + mailboxPath for the STORE command)
|
||||
(JMAP: includes just the flag map for Email/set)
|
||||
|
||||
[UI immediately reflects the change via Drift's reactive streams]
|
||||
|
||||
│
|
||||
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
|
||||
_SyncLoop._flush() / flushPendingChanges()
|
||||
│
|
||||
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
|
||||
│
|
||||
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
|
||||
If stateMismatch → clear checkpoint → full re-sync
|
||||
|
||||
│
|
||||
▼
|
||||
pending_changes row deleted on success
|
||||
(on permanent error: retry count incremented; evicted after 5 failures)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. IMAP sync loop
|
||||
|
||||
The IMAP loop runs one coroutine per account (`_AccountSync`):
|
||||
|
||||
```
|
||||
start()
|
||||
│
|
||||
▼
|
||||
[forever loop]
|
||||
├─ flushPendingChanges() ← drain outbound queue first
|
||||
├─ syncMailboxes() ← detect new/removed mailboxes
|
||||
├─ for each mailbox:
|
||||
│ syncEmails() ← incremental: fetch only UIDs > lastUid
|
||||
│ deletion reconciliation: remove rows
|
||||
│ whose UID is absent from the server
|
||||
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
|
||||
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
|
||||
│ or syncNow() signal from UI
|
||||
└─ repeat
|
||||
```
|
||||
|
||||
**Incremental sync checkpoint** — `sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
|
||||
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
||||
folder is re-scanned and the checkpoint is reset.
|
||||
|
||||
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
|
||||
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
||||
|
||||
---
|
||||
|
||||
## 4. JMAP sync loop
|
||||
|
||||
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
|
||||
|
||||
```
|
||||
start()
|
||||
│
|
||||
▼
|
||||
[forever loop]
|
||||
├─ flushPendingChanges() ← Email/set for queued mutations
|
||||
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
|
||||
├─ for each mailbox:
|
||||
│ syncEmails() ← Email/query + Email/get (first run)
|
||||
│ Email/changes (subsequent runs, state token)
|
||||
└─ _wait()
|
||||
├─ If server advertises eventSourceUrl: subscribe to SSE push
|
||||
│ wake on "Email" change event
|
||||
└─ Otherwise: sleep 30 s (poll fallback)
|
||||
```
|
||||
|
||||
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
|
||||
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
|
||||
a full re-fetch.
|
||||
|
||||
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
|
||||
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exponential backoff
|
||||
|
||||
Both loops share the same backoff policy:
|
||||
|
||||
| Outcome | Backoff |
|
||||
|---------|---------|
|
||||
| Sync succeeded | Reset to 5 s |
|
||||
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
|
||||
|
||||
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
|
||||
on the next successful cycle.
|
||||
|
||||
The last error message is written to `sync_log` and surfaced in the UI via
|
||||
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
|
||||
|
||||
---
|
||||
|
||||
## 6. Pending-change queue
|
||||
|
||||
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
|
||||
reaches the server:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| `id` | Auto-increment primary key |
|
||||
| `email_id` | The email being mutated |
|
||||
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
|
||||
| `payload` | JSON-encoded protocol-specific arguments |
|
||||
| `retry_count` | Incremented on each failed flush attempt |
|
||||
| `created_at` | For ordering and debug |
|
||||
|
||||
**Optimistic UI** — every mutation writes the local change first, then inserts into
|
||||
`pending_changes`. The Drift reactive stream delivers the update to the UI before
|
||||
the network round-trip completes.
|
||||
|
||||
**Conflict resolution** — the server always wins. On the next sync cycle the server's
|
||||
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
|
||||
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
|
||||
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
|
||||
|
||||
---
|
||||
|
||||
## 7. Undo and cancel
|
||||
|
||||
When the user triggers an undoable action the UI calls:
|
||||
```
|
||||
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
|
||||
```
|
||||
|
||||
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
|
||||
A `SnackBar` with an **Undo** button appears for a few seconds.
|
||||
|
||||
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
|
||||
|
||||
```
|
||||
1. cancelPendingChange(id, originalType)
|
||||
└─ Deletes the pending_changes row if it has not been flushed yet.
|
||||
Returns true if cancelled, false if the server already processed it.
|
||||
|
||||
2. If the email row was hard-deleted (DELETE action):
|
||||
restoreEmails([original])
|
||||
└─ Re-inserts the row with its pre-deletion state,
|
||||
placed in the correct mailbox (source if cancelled, dest otherwise).
|
||||
|
||||
3. moveEmail(id, sourceMailboxPath)
|
||||
└─ Optimistic local move back to the original folder.
|
||||
If step 1 returned false (already sent to server), this enqueues
|
||||
a reverse-move in pending_changes so the server move is undone too.
|
||||
|
||||
4. If step 1 returned true (cancelled before flush):
|
||||
cancelPendingChange(id, 'move')
|
||||
└─ The reverse-move from step 3 is redundant; remove it.
|
||||
```
|
||||
|
||||
The net result is: if the mutation was still in the queue it is silently cancelled with no
|
||||
server round-trip; if it had already been flushed, a compensating move is queued.
|
||||
|
||||
---
|
||||
|
||||
## 8. Key invariants
|
||||
|
||||
- **Order**: pending changes are flushed before syncing. This prevents the server from
|
||||
overwriting an optimistic local state that the server hasn't seen yet.
|
||||
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
|
||||
deleted only after the server acknowledges the change.
|
||||
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
|
||||
visible in the UI (Settings → Failed mutations).
|
||||
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
|
||||
through `core/` interfaces. The `check-layers` Taskfile task enforces this.
|
||||
+23
-330
@@ -1,9 +1,6 @@
|
||||
version: "3"
|
||||
silent: true
|
||||
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
||||
@@ -125,16 +122,6 @@ tasks:
|
||||
cmds:
|
||||
- fvm dart format lib test
|
||||
|
||||
check-mocks:
|
||||
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||
deps: [_preflight, _pub-get]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- test/**/*.dart
|
||||
- pubspec.yaml
|
||||
cmds:
|
||||
- scripts/check_mocks_fresh.sh
|
||||
|
||||
analyze-fix:
|
||||
desc: Auto-fix lint issues with dart fix --apply
|
||||
deps: [_preflight]
|
||||
@@ -174,175 +161,23 @@ tasks:
|
||||
cmds:
|
||||
- fvm flutter test
|
||||
|
||||
test-backend:
|
||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||
integration:
|
||||
desc: Integration tests against a local Stalwart mail server
|
||||
deps: [_flutter-check]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- test/integration/**/*.dart
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||
- stalwart-dev/test.sh
|
||||
|
||||
integration-ui:
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- integration_test/app_e2e_test.dart
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||
|
||||
sync-reliability:
|
||||
desc: Run sync reliability runner (via Dagger)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||
|
||||
test-android-firebase:
|
||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||
preconditions:
|
||||
- sh: test -n "$FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||
msg: "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY is not set"
|
||||
- sh: test -n "$FIREBASE_PROJECT_ID"
|
||||
msg: "FIREBASE_PROJECT_ID is not set"
|
||||
cmds:
|
||||
- scripts/run_firebase_test.sh
|
||||
|
||||
ci-graph:
|
||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. graph
|
||||
|
||||
stalwart:
|
||||
desc: Start a Stalwart instance for local development (via Dagger)
|
||||
cmds:
|
||||
- echo "Starting Stalwart on default ports (JMAP=8080, IMAP=1430, SMTP=1025, SIEVE=4190)"
|
||||
- dagger call --progress=plain -q -m ci --source=. stalwart up --ports 8080:8080 --ports 1430:1430 --ports 1025:1025 --ports 4190:4190
|
||||
|
||||
deploy-linux:
|
||||
desc: Build and deploy Linux release 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=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
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
|
||||
|
||||
upload-android-bundle:
|
||||
desc: Upload AAB from build/ to Play Store via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||
msg: "AAB not found — run build-android-bundle first"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||
|
||||
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"
|
||||
- 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=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||
|
||||
deploy-apk:
|
||||
desc: Build and deploy Android APK via Dagger
|
||||
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)"
|
||||
|
||||
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:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
cmds:
|
||||
- |
|
||||
DAGGER_OUT=$(mktemp)
|
||||
RC_FILE=$(mktemp)
|
||||
_ts() { date -u '+[%H:%M:%S]'; }
|
||||
run_dagger() {
|
||||
: > "$DAGGER_OUT"; : > "$RC_FILE"
|
||||
{ timeout --kill-after=10 600 "$@"; echo $? > "$RC_FILE"; } 2>&1 | tee "$DAGGER_OUT"
|
||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||
if [ "$RC" -eq 124 ] && grep -q "All tests passed" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: hung in teardown after success; treating as exit 0." >&2
|
||||
RC=0
|
||||
fi
|
||||
return "$RC"
|
||||
}
|
||||
retry_dagger() {
|
||||
for attempt in 1 2 3; do
|
||||
run_dagger "$@" && return 0
|
||||
RC=$?
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||
sleep 90
|
||||
else
|
||||
return "$RC"
|
||||
fi
|
||||
done
|
||||
}
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||
RC=$?
|
||||
rm -f "$DAGGER_OUT" "$RC_FILE"
|
||||
exit $RC
|
||||
fi
|
||||
PORTFILE=$(mktemp)
|
||||
python3 ci/otel-receiver.py --port-file="$PORTFILE" &
|
||||
RECV_PID=$!
|
||||
cleanup() {
|
||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||
PORT=$(cat "$PORTFILE")
|
||||
retry_dagger env \
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" \
|
||||
dagger call --progress=plain -q -m ci --source=. check
|
||||
RC=$?
|
||||
curl -sf "http://127.0.0.1:$PORT/shutdown" >/dev/null 2>&1 || true
|
||||
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
|
||||
- stalwart-dev/integration_ui_test.sh
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
@@ -381,87 +216,7 @@ tasks:
|
||||
generates:
|
||||
- build/linux/x64/release/bundle/sharedinbox
|
||||
cmds:
|
||||
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||
|
||||
deploy-linux-to-server:
|
||||
desc: Package and deploy the Linux release bundle to the server, update latest.json
|
||||
deps: [build-linux-release]
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
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"
|
||||
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 '{}'")
|
||||
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
||||
if [ -n "$WINDOWS_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $TARBALL and updated latest.json"
|
||||
|
||||
build-windows-release:
|
||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||
deps: [_pub-get, generate-changelog]
|
||||
method: timestamp
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- windows/**/*
|
||||
- pubspec.yaml
|
||||
generates:
|
||||
- build/windows/x64/runner/Release/sharedinbox.exe
|
||||
cmds:
|
||||
- fvm flutter build windows --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||
|
||||
deploy-windows-to-server:
|
||||
desc: Package and deploy the Windows release bundle to the server, update latest.json
|
||||
deps: [build-windows-release]
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
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"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||
EXISTING=$(ssh "$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"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub
|
||||
|
||||
|
||||
_android-avd-setup:
|
||||
@@ -511,19 +266,19 @@ tasks:
|
||||
generates:
|
||||
- build/app/outputs/flutter-apk/app-release.apk
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
desc: Build release AAB and upload to Play Store internal track
|
||||
deps: [build-android-bundle]
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
cmds:
|
||||
- python3 scripts/deploy_playstore.py
|
||||
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
build-android-bundle:
|
||||
desc: Build a release App Bundle (AAB)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
method: timestamp
|
||||
sources:
|
||||
@@ -533,7 +288,7 @@ tasks:
|
||||
generates:
|
||||
- build/app/outputs/bundle/release/app-release.aab
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
@@ -601,81 +356,19 @@ tasks:
|
||||
cmds:
|
||||
- scripts/website-verify.sh
|
||||
|
||||
deploy-apk-to-server:
|
||||
desc: SCP the release APK to the server at public_html/builds/YYYY/MM/DD/
|
||||
deps: [build-android]
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
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 \
|
||||
build/app/outputs/flutter-apk/app-release.apk \
|
||||
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||
|
||||
generate-build-history:
|
||||
desc: Generate Hugo build-history pages from Linux and Android builds on the server
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
cmds:
|
||||
- python3 scripts/generate_build_history.py
|
||||
|
||||
website-publish:
|
||||
desc: Generate build history, build Hugo site, and rsync to server (requires SSH_USER + SSH_HOST)
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
cmds:
|
||||
- task: generate-build-history
|
||||
- task: website-deploy
|
||||
|
||||
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/
|
||||
|
||||
check-fast:
|
||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||
|
||||
check-layers:
|
||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||
cmds:
|
||||
- |
|
||||
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
|
||||
echo "$VIOLATIONS"
|
||||
exit 1
|
||||
fi
|
||||
deps: [analyze, check-coverage, check-hygiene]
|
||||
|
||||
check-hygiene:
|
||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||
@@ -695,7 +388,7 @@ tasks:
|
||||
internal: true
|
||||
run: once
|
||||
cmds:
|
||||
- task: test-backend
|
||||
- task: integration
|
||||
- task: integration-ui
|
||||
|
||||
ci-logs:
|
||||
|
||||
@@ -4,6 +4,7 @@ gradle-wrapper.jar
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
|
||||
@@ -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,3 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
org.gradle.welcome=never
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/dagger.gen.go linguist-generated
|
||||
/internal/dagger/** linguist-generated
|
||||
/internal/querybuilder/** linguist-generated
|
||||
/internal/telemetry/** linguist-generated
|
||||
@@ -1,5 +0,0 @@
|
||||
/dagger.gen.go
|
||||
/internal/dagger
|
||||
/internal/querybuilder
|
||||
/internal/telemetry
|
||||
/.env
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "ci",
|
||||
"engineVersion": "v0.20.8",
|
||||
"sdk": {
|
||||
"source": "go"
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
module dagger/ci
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
||||
github.com/Khan/genqlient v0.8.1
|
||||
github.com/dagger/otel-go v1.43.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.33
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.90 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/sosodev/duration v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.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
|
||||
@@ -1,97 +0,0 @@
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
||||
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
||||
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
||||
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
-925
@@ -1,925 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"dagger/ci/internal/dagger"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// patchAabScript patches android:versionCode in an AAB's compiled manifest proto.
|
||||
// It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies.
|
||||
const patchAabScript = `#!/usr/bin/env python3
|
||||
import sys, zipfile
|
||||
|
||||
MANIFEST = "base/manifest/AndroidManifest.xml"
|
||||
VERSION_CODE_RID = 0x0101021b
|
||||
|
||||
def _vr(b, p):
|
||||
n = s = 0
|
||||
while True:
|
||||
c = b[p]; p += 1; n |= (c & 127) << s
|
||||
if not (c & 128): return n, p
|
||||
s += 7
|
||||
|
||||
def _ve(n):
|
||||
r = []
|
||||
while n > 127: r.append((n & 127) | 128); n >>= 7
|
||||
return bytes(r + [n])
|
||||
|
||||
def _parse(d):
|
||||
p = 0
|
||||
while p < len(d):
|
||||
tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7
|
||||
if wt == 0: v, p = _vr(d, p); yield fn, 0, v
|
||||
elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln
|
||||
elif wt == 5: yield fn, 5, d[p:p+4]; p += 4 # fixed32
|
||||
elif wt == 1: yield fn, 1, d[p:p+8]; p += 8 # fixed64
|
||||
else: raise ValueError(f"wire type {wt}")
|
||||
|
||||
def _enc(fn, wt, v):
|
||||
t = _ve((fn << 3) | wt)
|
||||
if wt == 0: return t + _ve(v)
|
||||
if wt in (1, 5): return t + v # fixed-width, pass bytes as-is
|
||||
return t + _ve(len(v)) + v
|
||||
|
||||
def _patch_prim(d, vc):
|
||||
# Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7),
|
||||
# whichever is present — AAPT2 may use either.
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _patch_item(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _has_rid(d):
|
||||
return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d))
|
||||
|
||||
def _patch_attr(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode())
|
||||
elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc))
|
||||
else: out += _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _patch_elem(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _patch_node(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _dump_proto(d, depth=0, limit=3):
|
||||
"""Print proto field structure for debugging."""
|
||||
pad = " " * depth
|
||||
for fn, wt, v in _parse(d):
|
||||
if wt == 0:
|
||||
print(f"{pad}[{fn}] varint={v} (0x{v:x})")
|
||||
elif wt == 2:
|
||||
print(f"{pad}[{fn}] bytes len={len(v)}")
|
||||
if depth < limit:
|
||||
_dump_proto(v, depth + 1, limit)
|
||||
elif wt == 5:
|
||||
print(f"{pad}[{fn}] fixed32={v.hex()}")
|
||||
elif wt == 1:
|
||||
print(f"{pad}[{fn}] fixed64={v.hex()}")
|
||||
|
||||
def _read_vc_from_node(d):
|
||||
"""Read versionCode from XmlNode proto bytes. Returns int or None."""
|
||||
for fn, wt, v in _parse(d):
|
||||
if fn == 1 and wt == 2: # XmlElement
|
||||
for efn, ewt, attr in _parse(v):
|
||||
if efn == 4 and ewt == 2 and _has_rid(attr): # XmlAttribute with versionCode RID
|
||||
for afn, awt, item in _parse(attr):
|
||||
if afn == 6 and awt == 2: # compiled_value (Item)
|
||||
for ifn, iwt, prim in _parse(item):
|
||||
if ifn == 7 and iwt == 2: # prim (Primitive)
|
||||
for pfn, pwt, pv in _parse(prim):
|
||||
if pfn in (6, 7) and pwt == 0:
|
||||
return pv
|
||||
return None
|
||||
|
||||
def patch(src, dst, vc):
|
||||
with zipfile.ZipFile(src) as z:
|
||||
mf = z.read(MANIFEST)
|
||||
|
||||
orig_vc = _read_vc_from_node(mf)
|
||||
if orig_vc is None:
|
||||
print("DEBUG: could not find versionCode — dumping manifest proto structure:")
|
||||
_dump_proto(mf, limit=4)
|
||||
sys.exit(f"ERROR: versionCode not found in {MANIFEST}")
|
||||
print(f"Original versionCode in manifest: {orig_vc}")
|
||||
|
||||
patched = _patch_node(mf, vc)
|
||||
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout:
|
||||
for info in zin.infolist():
|
||||
if info.filename.startswith('META-INF/'):
|
||||
continue # strip old signature; jarsigner re-signs after
|
||||
d = patched if info.filename == MANIFEST else zin.read(info.filename)
|
||||
zi = zipfile.ZipInfo(info.filename, info.date_time)
|
||||
zi.compress_type = info.compress_type
|
||||
zi.external_attr = info.external_attr
|
||||
zout.writestr(zi, d)
|
||||
|
||||
# Verify the patch actually took effect
|
||||
with zipfile.ZipFile(dst) as z:
|
||||
actual = _read_vc_from_node(z.read(MANIFEST))
|
||||
if actual != vc:
|
||||
sys.exit(f"ERROR: versionCode patch failed — wrote {vc} but read back {actual} (original was {orig_vc})")
|
||||
print(f"versionCode={actual} -> {dst}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode")
|
||||
patch(sys.argv[1], sys.argv[2], int(sys.argv[3]))
|
||||
`
|
||||
|
||||
type Ci struct {
|
||||
Source *dagger.Directory
|
||||
}
|
||||
|
||||
func New(
|
||||
// +defaultPath=".."
|
||||
source *dagger.Directory,
|
||||
) *Ci {
|
||||
return &Ci{
|
||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{
|
||||
"lib/",
|
||||
"test/",
|
||||
"assets/",
|
||||
"scripts/",
|
||||
"pubspec.yaml",
|
||||
"pubspec.lock",
|
||||
"analysis_options.yaml",
|
||||
"linux/",
|
||||
"android/",
|
||||
"integration_test/",
|
||||
"drift_schemas/",
|
||||
"stalwart-dev/",
|
||||
"website/",
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||
// Its execution cache key is stable until the image, apt packages, or SDK versions change.
|
||||
// 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.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"}).
|
||||
WithExec([]string{"/bin/sh", "-c",
|
||||
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
|
||||
`chown -R ci:ci "$flutter_dir"; ` +
|
||||
`[ -n "$ANDROID_HOME" ] && chown -R ci:ci "$ANDROID_HOME" || true; ` +
|
||||
`mkdir -p /src && chown ci:ci /src`}).
|
||||
WithEnvVariable("PUB_CACHE", "/home/ci/.pub-cache").
|
||||
WithEnvVariable("HOME", "/home/ci").
|
||||
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"})
|
||||
}
|
||||
|
||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||
// Use for Android/Gradle builds that need the Gradle cache.
|
||||
func (m *Ci) Base() *dagger.Container {
|
||||
return m.toolchain().
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// pubGetLayer runs flutter pub get with only pubspec.yaml + pubspec.lock as
|
||||
// inputs, then removes non-deterministic fields from both package_config.json
|
||||
// and .flutter-plugins-dependencies so the snapshot is byte-for-byte stable
|
||||
// across runs. Re-executes only when pubspec.yaml or pubspec.lock changes.
|
||||
// Packages land in the execution-cache snapshot (not a named volume) so that
|
||||
// dagger prune can reclaim space from stale pubspec.lock snapshots.
|
||||
func (m *Ci) pubGetLayer() *dagger.Container {
|
||||
pubspecOnly := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"pubspec.yaml", "pubspec.lock"},
|
||||
})
|
||||
return m.toolchain().
|
||||
WithDirectory("/src", pubspecOnly, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
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`}).
|
||||
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" +
|
||||
"g='.flutter-plugins-dependencies'\n" +
|
||||
"if os.path.exists(g):\n" +
|
||||
" d=json.load(open(g)); d.pop('date_created',None); json.dump(d,open(g,'w'))\n"})
|
||||
}
|
||||
|
||||
// codegenBase runs build_runner on the source subset common to all build
|
||||
// variants (lib/, test/, assets/, pubspec.*), excluding committed generated
|
||||
// files so the cache key is stable. All setup() calls share this single
|
||||
// Dagger cache entry, so build_runner compiles only once per pipeline run.
|
||||
func (m *Ci) codegenBase() *dagger.Container {
|
||||
codegenSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock"},
|
||||
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
|
||||
})
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", codegenSrc, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
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`})
|
||||
}
|
||||
|
||||
// setup overlays platform-specific source files onto the shared codegen base.
|
||||
// Generated files (*.g.dart, *.mocks.dart) are excluded from the overlay so
|
||||
// the freshly built output from codegenBase() is not overwritten by stale
|
||||
// committed copies.
|
||||
func (m *Ci) setup(src *dagger.Directory) *dagger.Container {
|
||||
return m.codegenBase().
|
||||
WithDirectory("/src", src.Filter(dagger.DirectoryFilterOpts{
|
||||
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
|
||||
}), dagger.ContainerWithDirectoryOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// Setup is the exported variant (CLI / Taskfile). Uses the full check source.
|
||||
func (m *Ci) Setup() *dagger.Container {
|
||||
return m.setup(m.checkSrc())
|
||||
}
|
||||
|
||||
// checkSrc is the source subset for static checks and unit tests.
|
||||
func (m *Ci) checkSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock", "analysis_options.yaml", "scripts/"},
|
||||
})
|
||||
}
|
||||
|
||||
// androidSrc is the source subset for Android builds.
|
||||
func (m *Ci) androidSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "android/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// firebaseSrc is the source subset for Firebase Test Lab builds (app + instrumented tests).
|
||||
func (m *Ci) firebaseSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "android/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// 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{
|
||||
Include: []string{"lib/", "linux/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// backendSrc is the source subset for IMAP/JMAP backend tests.
|
||||
func (m *Ci) backendSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "test/", "assets/", "scripts/", "stalwart-dev/", "pubspec.yaml", "pubspec.lock"},
|
||||
})
|
||||
}
|
||||
|
||||
// integrationSrc is the source subset for UI integration tests (runs on Linux desktop).
|
||||
func (m *Ci) integrationSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "linux/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// Hugo container for website builds
|
||||
func (m *Ci) Hugo() *dagger.Container {
|
||||
return dag.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 {
|
||||
return dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||
}
|
||||
|
||||
// Stalwart mail server service for backend and integration tests.
|
||||
func (m *Ci) Stalwart() *dagger.Service {
|
||||
stalwartSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"stalwart-dev/"},
|
||||
})
|
||||
config := stalwartSrc.Directory("stalwart-dev").File("config.toml")
|
||||
|
||||
dataDir := dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "sqlite"}).
|
||||
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
|
||||
WithExec([]string{"sqlite3", "/tmp/stalwart/data.sqlite", "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');"}).
|
||||
Directory("/tmp/stalwart")
|
||||
|
||||
return dag.Container().
|
||||
From("stalwartlabs/stalwart:v0.14.1").
|
||||
WithFile("/etc/stalwart/config.toml.orig", config).
|
||||
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
||||
WithDirectory("/tmp/stalwart", dataDir).
|
||||
WithExposedPort(8080). // JMAP
|
||||
WithExposedPort(1430). // IMAP
|
||||
WithExposedPort(1025). // SMTP
|
||||
WithExposedPort(4190). // ManageSieve
|
||||
WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}).
|
||||
AsService()
|
||||
}
|
||||
|
||||
// WithStalwart binds the Stalwart service and sets test environment variables.
|
||||
func (m *Ci) WithStalwart(container *dagger.Container) *dagger.Container {
|
||||
stalwart := m.Stalwart()
|
||||
return container.
|
||||
WithServiceBinding("stalwart", stalwart).
|
||||
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
|
||||
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
|
||||
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
|
||||
WithEnvVariable("STALWART_IMAP_PORT", "1430").
|
||||
WithEnvVariable("STALWART_SMTP_PORT", "1025").
|
||||
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
|
||||
WithEnvVariable("STALWART_USER_B", "alice@example.com").
|
||||
WithEnvVariable("STALWART_PASS_B", "secret").
|
||||
WithEnvVariable("STALWART_USER_C", "bob@example.com").
|
||||
WithEnvVariable("STALWART_PASS_C", "secret")
|
||||
}
|
||||
|
||||
// CheckHygiene checks that no forbidden home-directory files are in the source.
|
||||
func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
|
||||
return m.Base().
|
||||
WithDirectory("/src", m.Source, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c", "FORBIDDEN=\".ssh .bashrc .config .local .cache .gitconfig .android Android .gradle .pub-cache .dartServer .flutter .dart-cli-completion .atuin .bash_logout .profile .zcompdump .zshrc snap .emulator_console_auth_token .lesshst .metadata .tmux.conf\"; for f in $FORBIDDEN; do if [ -e \"$f\" ]; then echo \"ERROR: Forbidden file/dir found in source: $f\"; exit 1; fi; done; echo \"Hygiene check passed.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// CheckLayers enforces that ui/ does not import data/.
|
||||
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
|
||||
return m.Base().
|
||||
WithDirectory("/src", m.Source.Filter(dagger.DirectoryFilterOpts{Include: []string{"lib/"}}), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c", "VIOLATIONS=$(grep -rn \"package:sharedinbox/data/\" lib/ui/ 2>/dev/null || true); if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):\"; echo \"$VIOLATIONS\"; exit 1; fi; echo \"Layer check passed.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Format runs dart format check.
|
||||
func (m *Ci) Format(ctx context.Context) (string, error) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// CheckMocks verifies that generated mocks are up to date.
|
||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
// comparing two freshly-generated outputs.
|
||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"git", "init"}).
|
||||
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
||||
WithExec([]string{"git", "config", "user.name", "CI"}).
|
||||
WithExec([]string{"git", "add", "."}).
|
||||
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
||||
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`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Coverage runs unit tests with coverage gate.
|
||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// TestBackend runs IMAP/JMAP sync tests against a live Stalwart instance.
|
||||
func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// TestIntegration runs UI integration tests via Xvfb.
|
||||
func (m *Ci) TestIntegration(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.integrationSrc())).
|
||||
WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`xvfb-run -s '-screen 0 1280x720x24' flutter test integration_test/ -d linux >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// TestSyncReliability runs the sync reliability runner.
|
||||
func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/backend/sync_reliability_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Check runs the full check suite.
|
||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
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())
|
||||
|
||||
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||
return "Format check failed", err
|
||||
}
|
||||
|
||||
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||
if err != nil {
|
||||
return analyze, err
|
||||
}
|
||||
|
||||
mocks, err := m.CheckMocks(ctx)
|
||||
if err != nil {
|
||||
return mocks, err
|
||||
}
|
||||
|
||||
coverage, err := m.Coverage(ctx)
|
||||
if err != nil {
|
||||
return coverage, err
|
||||
}
|
||||
|
||||
var testBackend, testIntegration string
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testBackend, e = m.TestBackend(egCtx)
|
||||
return e
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testIntegration, e = m.TestIntegration(egCtx)
|
||||
return e
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, mocks, coverage, testBackend, testIntegration), nil
|
||||
}
|
||||
|
||||
// GenerateBuildHistory scans the remote server and produces Hugo content.
|
||||
func (m *Ci) GenerateBuildHistory(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"scripts/generate_build_history.py", "website/"},
|
||||
})
|
||||
|
||||
return dag.Container().
|
||||
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}).
|
||||
WithEnvVariable("SSH_USER", sshUser).
|
||||
WithEnvVariable("SSH_HOST", sshHost).
|
||||
WithDirectory("/src", scriptSource).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/sh", "-c", "python3 scripts/generate_build_history.py"}).
|
||||
Directory("website/content/builds")
|
||||
}
|
||||
|
||||
// BuildWebsite builds the Hugo-based website.
|
||||
func (m *Ci) BuildWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"website/"},
|
||||
}).WithDirectory("website/content/builds", buildHistory)
|
||||
|
||||
return m.Hugo().
|
||||
WithDirectory("/src", websiteSource).
|
||||
WithWorkdir("/src/website").
|
||||
WithExec([]string{"hugo", "--minify"}).
|
||||
Directory("public")
|
||||
}
|
||||
|
||||
// PublishWebsite builds and deploys the website to the remote server.
|
||||
func (m *Ci) PublishWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) (string, error) {
|
||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithDirectory("/public", public).
|
||||
WithExec([]string{"rsync", "-avz", "--delete",
|
||||
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||
"/public/", fmt.Sprintf("%s@%s:public_html/", sshUser, sshHost)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// BuildLinux builds the Linux release bundle.
|
||||
func (m *Ci) BuildLinux() *dagger.Directory {
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec(args).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
// DeployLinux packages and deploys the Linux release to the server.
|
||||
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)
|
||||
|
||||
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).
|
||||
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)}).
|
||||
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().
|
||||
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)
|
||||
}
|
||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||
WithExec(args).
|
||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||
}
|
||||
|
||||
// DeployApk builds and deploys the APK to the server.
|
||||
func (m *Ci) DeployApk(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
) (string, error) {
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
||||
|
||||
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).
|
||||
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)}).
|
||||
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().
|
||||
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"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
|
||||
[ -n "$apk" ] || { echo "ERROR: no androidTest APK found; APKs present:"; find /src -name "*.apk" -type f 2>/dev/null; exit 1; } && \
|
||||
echo "Found test APK: $apk" && \
|
||||
cp "$apk" /src/app-debug-androidTest.apk`})
|
||||
|
||||
return dag.Directory().
|
||||
WithFile("app-debug.apk",
|
||||
built.File("build/app/outputs/flutter-apk/app-debug.apk")).
|
||||
WithFile("app-debug-androidTest.apk",
|
||||
built.File("app-debug-androidTest.apk"))
|
||||
}
|
||||
|
||||
// TestAndroidFirebase builds Android APKs and runs instrumented tests on Firebase Test Lab.
|
||||
func (m *Ci) TestAndroidFirebase(
|
||||
ctx context.Context,
|
||||
serviceAccountKey *dagger.Secret,
|
||||
projectID string,
|
||||
) (string, error) {
|
||||
apks := m.BuildAndroidDebugApks()
|
||||
|
||||
return dag.Container().
|
||||
From("google/cloud-sdk:slim").
|
||||
WithDirectory("/apks", apks).
|
||||
WithSecretVariable("FIREBASE_SA_KEY", serviceAccountKey).
|
||||
WithEnvVariable("FIREBASE_PROJECT_ID", projectID).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`auth_err=$(mktemp); trap 'rm -f "$auth_err"' EXIT; \
|
||||
gcloud auth activate-service-account --key-file=<(echo "$FIREBASE_SA_KEY") 2>"$auth_err" \
|
||||
|| { cat "$auth_err"; exit 1; }; \
|
||||
gcloud config set project "$FIREBASE_PROJECT_ID" 2>>"$auth_err" \
|
||||
|| { cat "$auth_err"; exit 1; }; \
|
||||
unknown=$(grep -vF "Activated service account credentials for:" "$auth_err" \
|
||||
| grep -vF "Updated property [core/project]." | grep -v "^$" || true); \
|
||||
[ -z "$unknown" ] || { echo "ERROR: unexpected gcloud auth output: $unknown"; exit 1; }; \
|
||||
out=$(gcloud firebase test android run \
|
||||
--type instrumentation \
|
||||
--app /apks/app-debug.apk \
|
||||
--test /apks/app-debug-androidTest.apk \
|
||||
--device model=oriole,version=33,locale=en,orientation=portrait \
|
||||
--results-bucket=gs://sharedinbox-ftl-results 2>&1); rc=$?; echo "$out"; \
|
||||
[ "$rc" -eq 0 ] || { echo "ERROR: gcloud firebase test exited with code $rc"; exit "$rc"; }; \
|
||||
expected_devices=1; \
|
||||
actual_devices=$(echo "$out" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || actual_devices=0; \
|
||||
[ "$actual_devices" -eq "$expected_devices" ] || \
|
||||
{ echo "ERROR: expected $expected_devices test result(s) but found $actual_devices"; exit 1; }; \
|
||||
echo "$out" | grep -q "Passed" || { echo "ERROR: no passing test results — tests failed or did not run"; exit 1; }`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// 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).
|
||||
File("build/app/outputs/bundle/release/app-release.aab")
|
||||
}
|
||||
|
||||
// withGoCache mounts Dagger cache volumes for GOCACHE and GOMODCACHE so Go
|
||||
// builds inside the container reuse cached packages between pipeline runs.
|
||||
func withGoCache(c *dagger.Container) *dagger.Container {
|
||||
return c.
|
||||
WithMountedCache("/home/ci/.cache/go-build", dag.CacheVolume("go-build-cache")).
|
||||
WithMountedCache("/home/ci/go/pkg/mod", dag.CacheVolume("go-mod-cache")).
|
||||
WithEnvVariable("GOCACHE", "/home/ci/.cache/go-build").
|
||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||
}
|
||||
|
||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
||||
func (m *Ci) UploadToPlayStore(
|
||||
ctx context.Context,
|
||||
aab *dagger.File,
|
||||
playStoreConfig *dagger.Secret,
|
||||
) (string, error) {
|
||||
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"scripts/deploy_playstore.py"},
|
||||
})
|
||||
|
||||
return dag.Container().
|
||||
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"}).
|
||||
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).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"python3", "scripts/deploy_playstore.py"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// StampAndroidVersionCode patches the versionCode in a built AAB without rebuilding.
|
||||
func (m *Ci) StampAndroidVersionCode(aab *dagger.File, versionCode int) *dagger.File {
|
||||
return dag.Container().
|
||||
From("python:3.12-alpine").
|
||||
WithNewFile("/patch.py", patchAabScript).
|
||||
WithFile("/in.aab", aab).
|
||||
WithExec([]string{"python3", "/patch.py", "/in.aab", "/out.aab", fmt.Sprintf("%d", versionCode)}).
|
||||
File("/out.aab")
|
||||
}
|
||||
|
||||
// SignAndroidBundle signs an AAB with the release upload key via jarsigner.
|
||||
func (m *Ci) SignAndroidBundle(aab *dagger.File, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.File {
|
||||
return dag.Container().
|
||||
From("eclipse-temurin:17-jdk-alpine").
|
||||
WithFile("/app.aab", aab).
|
||||
WithSecretVariable("KS_BASE64", keystoreBase64).
|
||||
WithSecretVariable("KS_PASS", keystorePassword).
|
||||
WithExec([]string{"sh", "-c",
|
||||
`[ -n "$KS_BASE64" ] || { echo "ERROR: KS_BASE64 secret is empty — ANDROID_KEYSTORE_BASE64 not set"; exit 1; }
|
||||
[ -n "$KS_PASS" ] || { echo "ERROR: KS_PASS secret is empty — ANDROID_KEYSTORE_PASSWORD not set"; exit 1; }
|
||||
echo "$KS_BASE64" | base64 -d > /keystore.jks && \
|
||||
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||
-signedjar /signed.aab \
|
||||
-keystore /keystore.jks \
|
||||
-storepass "$KS_PASS" -keypass "$KS_PASS" \
|
||||
/app.aab upload`}).
|
||||
File("/signed.aab")
|
||||
}
|
||||
|
||||
// PublishAndroid builds a cached AAB, stamps the versionCode, re-signs, and uploads to Play Store.
|
||||
func (m *Ci) PublishAndroid(
|
||||
ctx context.Context,
|
||||
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)
|
||||
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) {
|
||||
return dag.Container().
|
||||
From("renovate/renovate:39").
|
||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||
WithEnvVariable("LOG_LEVEL", "info").
|
||||
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.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dagger call --progress=plain -q -m ci --source=. graph
|
||||
func (m *Ci) Graph() string {
|
||||
return `# CI Pipeline Graph
|
||||
|
||||
` + "```" + `mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
||||
pubGet["pubGetLayer\nflutter pub get"]
|
||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||
|
||||
toolchain --> pubGet
|
||||
pubGet --> codegen
|
||||
|
||||
pubGet --> hygiene["CheckHygiene"]
|
||||
pubGet --> layers["CheckLayers"]
|
||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||
|
||||
codegen --> fmt["Format"]
|
||||
codegen --> analyze["Analyze"]
|
||||
codegen --> coverage["Coverage\nunit tests + gate"]
|
||||
codegen --> backend["TestBackend\nIMAP / JMAP"]
|
||||
codegen --> integration["TestIntegration\nXvfb · Linux desktop"]
|
||||
|
||||
stalwart --> backend
|
||||
stalwart --> integration
|
||||
|
||||
hygiene --> check{{"✓ Check"}}
|
||||
layers --> check
|
||||
fmt --> check
|
||||
analyze --> check
|
||||
mocks --> check
|
||||
coverage --> check
|
||||
backend --> check
|
||||
integration --> check
|
||||
end
|
||||
|
||||
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
|
||||
ciCheck["check"]
|
||||
end
|
||||
|
||||
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
|
||||
buildLinux --> pubWeb
|
||||
deployPS --> pubWeb
|
||||
deployApk --> pubWeb
|
||||
end
|
||||
|
||||
check -- "task check-dagger" --> ciCheck
|
||||
` + "```"
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal OTLP HTTP/protobuf trace receiver for Dagger CI timing.
|
||||
|
||||
Usage:
|
||||
python3 ci/otel-receiver.py --port-file=/tmp/otel.port
|
||||
|
||||
Caller sets:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
# ── Minimal protobuf binary decoder ─────────────────────────────────────────
|
||||
# Only decodes the fields we need; skips everything else safely.
|
||||
|
||||
def _varint(buf, pos):
|
||||
n, shift = 0, 0
|
||||
while pos < len(buf):
|
||||
b = buf[pos]; pos += 1
|
||||
n |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not (b & 0x80):
|
||||
return n, pos
|
||||
raise ValueError("truncated varint")
|
||||
|
||||
|
||||
def _fields(buf):
|
||||
"""Yield (field_num, wire_type, raw_value) for each field in a message."""
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
tag, pos = _varint(buf, pos)
|
||||
wt, fn = tag & 7, tag >> 3
|
||||
if wt == 0: # varint
|
||||
v, pos = _varint(buf, pos)
|
||||
elif wt == 1: # fixed64
|
||||
v = struct.unpack_from("<Q", buf, pos)[0]; pos += 8
|
||||
elif wt == 2: # length-delimited
|
||||
n, pos = _varint(buf, pos)
|
||||
v = buf[pos:pos + n]; pos += n
|
||||
elif wt == 5: # fixed32
|
||||
v = struct.unpack_from("<I", buf, pos)[0]; pos += 4
|
||||
else:
|
||||
break # unknown: stop
|
||||
yield fn, wt, v
|
||||
|
||||
|
||||
def _any_value(buf):
|
||||
"""Parse AnyValue, return (type_tag, python_value)."""
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 1 and wt == 2: # string_value
|
||||
return "str", v.decode("utf-8", errors="replace")
|
||||
if fn == 2 and wt == 0: # bool_value
|
||||
return "bool", bool(v)
|
||||
if fn == 3 and wt == 0: # int_value (sint64)
|
||||
return "int", v
|
||||
if fn == 4 and wt == 1: # double_value
|
||||
return "float", struct.unpack("<d", struct.pack("<Q", v))[0]
|
||||
return None, None
|
||||
|
||||
|
||||
def _keyvalue(buf):
|
||||
key, tag, val = None, None, None
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 1 and wt == 2:
|
||||
key = v.decode("utf-8", errors="replace")
|
||||
elif fn == 2 and wt == 2:
|
||||
tag, val = _any_value(v)
|
||||
return key, tag, val
|
||||
|
||||
|
||||
def _span(buf):
|
||||
name = ""
|
||||
start_ns = end_ns = 0
|
||||
cached = False
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 5 and wt == 2: # name
|
||||
name = v.decode("utf-8", errors="replace")
|
||||
elif fn == 7 and wt == 1: # start_time_unix_nano
|
||||
start_ns = v
|
||||
elif fn == 8 and wt == 1: # end_time_unix_nano
|
||||
end_ns = v
|
||||
elif fn == 9 and wt == 2: # attributes (repeated)
|
||||
k, tag, val = _keyvalue(v)
|
||||
if tag == "bool" and k and "cached" in k.lower():
|
||||
cached = val
|
||||
return {"name": name, "dur": max(0.0, (end_ns - start_ns) / 1e9), "cached": cached}
|
||||
|
||||
|
||||
def _decode(body):
|
||||
spans = []
|
||||
for fn1, wt1, rs in _fields(body): # resource_spans = 1
|
||||
if fn1 != 1 or wt1 != 2:
|
||||
continue
|
||||
for fn2, wt2, ss in _fields(rs): # scope_spans = 2
|
||||
if fn2 != 2 or wt2 != 2:
|
||||
continue
|
||||
for fn3, wt3, sp in _fields(ss): # spans = 2
|
||||
if fn3 == 2 and wt3 == 2:
|
||||
spans.append(_span(sp))
|
||||
return spans
|
||||
|
||||
|
||||
# ── HTTP receiver ────────────────────────────────────────────────────────────
|
||||
|
||||
_spans = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _respond(self, code, body=b""):
|
||||
self.close_connection = True # actually close after response, matching the header
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/x-protobuf")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path != "/shutdown":
|
||||
self._respond(404); return
|
||||
self._respond(200, b"shutting down")
|
||||
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/v1/traces":
|
||||
self._respond(404); return
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n)
|
||||
try:
|
||||
decoded = _decode(body)
|
||||
except Exception as exc:
|
||||
print(f"[otel-receiver] decode error: {exc}", file=sys.stderr, flush=True)
|
||||
self._respond(400, str(exc).encode()); return
|
||||
with _lock:
|
||||
_spans.extend(decoded)
|
||||
self._respond(200)
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
|
||||
# ── Timing report ────────────────────────────────────────────────────────────
|
||||
|
||||
def _report():
|
||||
with _lock:
|
||||
if not _spans:
|
||||
print("otel-receiver: no spans received", file=sys.stderr)
|
||||
return
|
||||
rows = sorted(_spans, key=lambda r: r["dur"], reverse=True)
|
||||
NAME_W = 38
|
||||
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
|
||||
print("─" * (6 + 2 + 8 + 2 + NAME_W + 20))
|
||||
for r in rows:
|
||||
status = "CACHED" if r["cached"] else "LIVE"
|
||||
name = r["name"]
|
||||
if len(name) > NAME_W:
|
||||
name = name[: NAME_W - 1] + "…"
|
||||
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
|
||||
print(f"\n{len(rows)} spans total")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port-file", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
if args.port_file:
|
||||
with open(args.port_file, "w") as f:
|
||||
f.write(str(server.server_address[1]))
|
||||
|
||||
def _shutdown(sig, frame):
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
server.serve_forever()
|
||||
_report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,48 +3,3 @@
|
||||
Installed like explained here:
|
||||
|
||||
https://forgejo.org/docs/next/admin/actions/installation/binary/
|
||||
|
||||
## Connecting to Dagger (via stunnel)
|
||||
|
||||
Dagger is running on the host machine and exported via stunnel on port 8774. The runner connects to it using a local stunnel client.
|
||||
|
||||
The following TLS secrets must be configured as environment variables in Codeberg:
|
||||
- `DAGGER_CLIENT_CERT`: Content of `client.crt`
|
||||
- `DAGGER_CLIENT_KEY`: Content of `client.key`
|
||||
- `DAGGER_CA_CERT`: Content of `ca.crt`
|
||||
|
||||
### Setup Script
|
||||
|
||||
This snippet can be used in a CI job to establish the connection:
|
||||
|
||||
```bash
|
||||
# Write TLS files from environment variables
|
||||
mkdir -p /etc/dagger/tls
|
||||
echo "$DAGGER_CLIENT_CERT" > /etc/dagger/tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /etc/dagger/tls/client.key
|
||||
echo "$DAGGER_CA_CERT" > /etc/dagger/tls/ca.crt
|
||||
|
||||
# Create stunnel configuration
|
||||
cat > /tmp/dagger-client.conf << EOF
|
||||
foreground = yes
|
||||
pid =
|
||||
|
||||
[dagger]
|
||||
client = yes
|
||||
accept = 127.0.0.1:1774
|
||||
connect = <server-ip>:8774
|
||||
cert = /etc/dagger/tls/client.crt
|
||||
key = /etc/dagger/tls/client.key
|
||||
CAfile = /etc/dagger/tls/ca.crt
|
||||
verify = 2
|
||||
EOF
|
||||
|
||||
# Start stunnel in the background
|
||||
stunnel /tmp/dagger-client.conf &
|
||||
|
||||
# Configure Dagger to use the tunnel
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
dagger version
|
||||
```
|
||||
|
||||
Note: Replace `<server-ip>` with the actual IP address of the machine running Dagger.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Load .env into environment
|
||||
set -a
|
||||
# shellcheck source=.env
|
||||
source "$REPO_DIR/.env"
|
||||
set +a
|
||||
|
||||
# SSH_PRIVATE_KEY must not live in .env (dagger parses .env and chokes on multiline values)
|
||||
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
|
||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||
done
|
||||
|
||||
exec python3 "$REPO_DIR/deploy_cron.py"
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/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.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).parent.resolve()
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
REPO = 'guettli/sharedinbox'
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(
|
||||
['git', *args], cwd=REPO_DIR, check=True,
|
||||
capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text().strip() if path.exists() else ''
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
git('fetch', 'origin', 'main')
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
|
||||
return
|
||||
remote_sha = git('rev-parse', 'origin/main')
|
||||
last_sha = read(SHA_FILE)
|
||||
|
||||
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...')
|
||||
result = subprocess.run(
|
||||
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SHA_FILE.write_text(remote_sha + '\n')
|
||||
print('Workflow triggered.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Generated
+3
-24
@@ -1,25 +1,5 @@
|
||||
{
|
||||
"nodes": {
|
||||
"dagger": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778107833,
|
||||
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
|
||||
"owner": "dagger",
|
||||
"repo": "nix",
|
||||
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "dagger",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -40,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
"lastModified": 1778430510,
|
||||
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -56,7 +36,6 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"dagger": "dagger",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
dagger.url = "github:dagger/nix";
|
||||
dagger.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, dagger }:
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# All Linux desktop runtime libraries needed by flutter build linux and
|
||||
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
||||
@@ -29,11 +27,7 @@
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
harfbuzz
|
||||
# Dagger remote setup dependencies
|
||||
stunnel
|
||||
netcat
|
||||
];
|
||||
|
||||
fgj = pkgs.stdenv.mkDerivation {
|
||||
pname = "fgj";
|
||||
version = "0.4.0";
|
||||
@@ -51,13 +45,8 @@
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger.packages.${system}.dagger
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
|
||||
# Java JDK — required by Gradle for Android builds
|
||||
jdk17
|
||||
|
||||
# Task runner
|
||||
go-task
|
||||
|
||||
@@ -112,28 +112,12 @@ void main() {
|
||||
late String userPass;
|
||||
|
||||
setUpAll(() {
|
||||
const required = [
|
||||
'STALWART_IMAP_HOST',
|
||||
'STALWART_IMAP_PORT',
|
||||
'STALWART_SMTP_HOST',
|
||||
'STALWART_SMTP_PORT',
|
||||
'STALWART_USER_B',
|
||||
'STALWART_PASS_B',
|
||||
];
|
||||
final missing = required.where((k) => Platform.environment[k] == null).toList();
|
||||
if (missing.isNotEmpty) {
|
||||
fail(
|
||||
'Missing required environment variables: ${missing.join(', ')}. '
|
||||
'This test requires a running Stalwart instance — '
|
||||
'run via stalwart-dev/integration_ui_test.sh.',
|
||||
);
|
||||
}
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST']!;
|
||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT']!);
|
||||
smtpHost = Platform.environment['STALWART_SMTP_HOST']!;
|
||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT']!);
|
||||
userEmail = Platform.environment['STALWART_USER_B']!;
|
||||
userPass = Platform.environment['STALWART_PASS_B']!;
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
|
||||
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
|
||||
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
||||
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -146,12 +130,17 @@ void main() {
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
// Capture the test binding's error recorder and error-widget builder
|
||||
// BEFORE app.main() so teardown can restore both. app.main() overwrites
|
||||
// FlutterError.onError (crash-screen handler) and ErrorWidget.builder;
|
||||
// the test binding verifies both are unchanged after the test completes.
|
||||
final bindingError = FlutterError.onError;
|
||||
final bindingErrorWidgetBuilder = ErrorWidget.builder;
|
||||
// On Android, the keyboard-dismiss / window-resize cycle can trigger
|
||||
// one final layout pass on already-disposed render objects (DEFUNCT).
|
||||
// These spurious overflow errors have no effect on real functionality;
|
||||
// filter them so they don't fail the test.
|
||||
final prevError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
final msg = details.toString();
|
||||
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
||||
prevError?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = prevError);
|
||||
|
||||
_log('app start');
|
||||
app.main(
|
||||
@@ -166,36 +155,7 @@ void main() {
|
||||
accountConnectionStatusProvider.overrideWith((ref, _) async {}),
|
||||
],
|
||||
);
|
||||
|
||||
// app.main() sets both FlutterError.onError (crash handler) and
|
||||
// ErrorWidget.builder (CrashScreen builder). The binding captures
|
||||
// ErrorWidget.builder BEFORE testBody() and verifies it is unchanged
|
||||
// AFTER testBody() returns — addTearDown fires too late for that check.
|
||||
// Restore ErrorWidget.builder here, immediately after app.main().
|
||||
ErrorWidget.builder = bindingErrorWidgetBuilder;
|
||||
|
||||
// Override the crash handler with a filter that forwards non-spurious
|
||||
// errors to the binding's recorder. addTearDown is fine for
|
||||
// FlutterError.onError because the binding checks it via _recordError
|
||||
// which is called on the next error, not in a post-body verify pass.
|
||||
FlutterError.onError = (details) {
|
||||
final msg = details.toString();
|
||||
// DEFUNCT/DISPOSED: keyboard-dismiss or teardown layout errors on
|
||||
// Android/Linux that have no effect on real functionality.
|
||||
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
||||
// _zOrderIndex: Flutter 3.41.6 bug — _RawAutocompleteState.dispose()
|
||||
// removes _updateOptionsViewVisibility from the external FocusNode but
|
||||
// forgets to remove _onFocusChange. When the state is rebuilt with the
|
||||
// same FocusNode both listeners accumulate and the second hide() call
|
||||
// hits the _zOrderIndex != null assertion in overlay.dart:1681.
|
||||
// Tracked upstream: https://github.com/flutter/flutter/issues
|
||||
// This filter must be removed once we upgrade past the fix.
|
||||
if (msg.contains('_zOrderIndex')) return;
|
||||
bindingError?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = bindingError);
|
||||
|
||||
await pumpUntil(tester, find.text('Welcome to sharedinbox.de'));
|
||||
await pumpUntil(tester, find.text('No accounts yet.'));
|
||||
_log('app settled');
|
||||
|
||||
// ── Add account ────────────────────────────────────────────────────────
|
||||
@@ -288,12 +248,6 @@ void main() {
|
||||
find.widgetWithText(TextFormField, 'To'),
|
||||
userEmail,
|
||||
);
|
||||
// Explicitly unfocus the To field so RawAutocomplete closes its overlay
|
||||
// via a single FocusNode notification BEFORE Subject takes focus.
|
||||
// A plain pump() is insufficient — the double hide() fires synchronously
|
||||
// during the focus-dispatch triggered by the next enterText call.
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Subject'),
|
||||
subject,
|
||||
@@ -303,10 +257,6 @@ void main() {
|
||||
await tester.ensureVisible(bodyField);
|
||||
await tester.enterText(bodyField, 'Hello from integration test!');
|
||||
|
||||
// Unfocus before sending so the autocomplete overlay closes cleanly
|
||||
// before ComposeScreen is popped, avoiding a second hide() on unmount.
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pump();
|
||||
_log('send email');
|
||||
await tester.tap(find.byIcon(Icons.send));
|
||||
// Wait for ComposeScreen to pop back to EmailListScreen after send.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
const int dbSchemaVersion = 33;
|
||||
@@ -232,29 +232,12 @@ class EmailHeader {
|
||||
}
|
||||
|
||||
/// Full message body — fetched on demand, cached in the local DB.
|
||||
class MimePart {
|
||||
final String contentType;
|
||||
final String? filename;
|
||||
final int? size;
|
||||
final String? encoding;
|
||||
final List<MimePart> children;
|
||||
|
||||
const MimePart({
|
||||
required this.contentType,
|
||||
this.filename,
|
||||
this.size,
|
||||
this.encoding,
|
||||
this.children = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class EmailBody {
|
||||
final String emailId;
|
||||
final String? textBody;
|
||||
final String? htmlBody;
|
||||
final List<EmailAttachment> attachments;
|
||||
final List<EmailHeader> headers;
|
||||
final MimePart? mimeTree;
|
||||
|
||||
const EmailBody({
|
||||
required this.emailId,
|
||||
@@ -262,7 +245,6 @@ class EmailBody {
|
||||
this.htmlBody,
|
||||
required this.attachments,
|
||||
this.headers = const [],
|
||||
this.mimeTree,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,6 @@ abstract class EmailRepository {
|
||||
/// return the cached path without a network round-trip.
|
||||
Future<String> downloadAttachment(String emailId, EmailAttachment attachment);
|
||||
|
||||
/// Fetches the original RFC 2822 message from the server as a raw string.
|
||||
/// Always performs a live network request — the raw message is not cached.
|
||||
Future<String> fetchRawRfc822(String emailId);
|
||||
|
||||
/// Returns emails in [mailboxPath] whose subject or body contain [query].
|
||||
/// Results come from the server (IMAP SEARCH) and are not cached.
|
||||
Future<List<Email>> searchEmails(
|
||||
@@ -61,14 +57,6 @@ abstract class EmailRepository {
|
||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||
|
||||
/// Returns unique email addresses from the local cache whose email or display
|
||||
/// name contains [query]. Results are deduplicated and capped at [limit].
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
String? accountId,
|
||||
String query, {
|
||||
int limit = 10,
|
||||
});
|
||||
|
||||
/// Sends any queued local mutations for [accountId] to the server.
|
||||
/// Returns the number of changes successfully applied.
|
||||
Future<int> flushPendingChanges(String accountId, String password);
|
||||
@@ -99,17 +87,6 @@ abstract class EmailRepository {
|
||||
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
||||
Future<void> restoreEmails(List<Email> emails);
|
||||
|
||||
/// Finds an email in [accountId]'s mailboxes by its RFC 2822 Message-ID header.
|
||||
/// Returns null if not found. Used during undo to locate an email after its
|
||||
/// IMAP UID changed (e.g. after a server-applied move assigned a new UID).
|
||||
Future<Email?> findEmailByMessageId(String accountId, String messageId);
|
||||
|
||||
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
||||
/// been processed yet. Records each processed email in LocalSieveApplied so
|
||||
/// the same email is never filtered twice (across restarts or re-syncs).
|
||||
/// Returns the number of emails where a rule matched and an action was taken.
|
||||
Future<int> applySieveRules(String accountId);
|
||||
|
||||
/// Emits the accountId whenever a new change is enqueued locally.
|
||||
/// Used by AccountSyncManager to trigger an immediate flush.
|
||||
Stream<String> get onChangesQueued;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
abstract interface class SearchHistoryRepository {
|
||||
Future<List<String>> getRecentSearches();
|
||||
Future<void> saveSearch(String query);
|
||||
Future<void> clearHistory();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
|
||||
/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing.
|
||||
abstract class ShareKeyRepository {
|
||||
/// Generates a new key pair and persists it with a 20-minute expiry.
|
||||
Future<ShareKeyMaterial> createKeyPair();
|
||||
|
||||
/// Returns the key pair whose ID matches [keyId], or null if not found /
|
||||
/// expired.
|
||||
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
|
||||
}
|
||||
@@ -4,14 +4,12 @@ class MailboxSyncStats {
|
||||
required this.fetched,
|
||||
required this.skipped,
|
||||
required this.bytesTransferred,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
final String mailboxPath;
|
||||
final int fetched;
|
||||
final int skipped;
|
||||
final int bytesTransferred;
|
||||
final Duration? duration;
|
||||
}
|
||||
|
||||
class SyncLogEntry {
|
||||
@@ -19,8 +17,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 +32,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 +52,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 +79,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,40 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
const _kChannelId = 'new_mail';
|
||||
const _kChannelName = 'New mail';
|
||||
|
||||
final _plugin = FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initNotifications() async {
|
||||
try {
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
await _plugin.initialize(
|
||||
settings: const InitializationSettings(android: android),
|
||||
onDidReceiveNotificationResponse: (_) {},
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
_initialized = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; notifications silently disabled.
|
||||
} catch (_) {
|
||||
// Unexpected initialization failure; notifications silently disabled.
|
||||
}
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
await _plugin.initialize(
|
||||
const InitializationSettings(android: android),
|
||||
onDidReceiveNotificationResponse: (_) {},
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
}
|
||||
|
||||
Future<void> showNewMailNotification(String accountEmail) async {
|
||||
if (!Platform.isAndroid || !_initialized) return;
|
||||
if (!Platform.isAndroid) 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,
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
const _pubKeyPrefix = 'sharedinbox.de:pubkey:v1:';
|
||||
const _encAccountsPrefix = 'sharedinbox.de:encrypted-accounts:v1:';
|
||||
|
||||
// ECIES wire sizes (bytes).
|
||||
const _keyIdLen = 16;
|
||||
const _pubKeyLen = 32;
|
||||
const _nonceLen = 12;
|
||||
const _macLen = 16;
|
||||
|
||||
/// Describes a freshly generated key pair before it is written to the database.
|
||||
class ShareKeyMaterial {
|
||||
const ShareKeyMaterial({
|
||||
required this.keyId,
|
||||
required this.publicKeyBytes,
|
||||
required this.privateKeyBytes,
|
||||
});
|
||||
|
||||
/// Random 16-byte identifier (hex-encoded when stored / included in QR).
|
||||
final Uint8List keyId;
|
||||
|
||||
/// X25519 public key, 32 bytes.
|
||||
final Uint8List publicKeyBytes;
|
||||
|
||||
/// X25519 private key, 32 bytes.
|
||||
final Uint8List privateKeyBytes;
|
||||
}
|
||||
|
||||
/// An account + password pair, used in the plaintext payload before encryption.
|
||||
class AccountPayload {
|
||||
const AccountPayload({required this.accountJson, required this.password});
|
||||
|
||||
final Map<String, dynamic> accountJson;
|
||||
final String password;
|
||||
}
|
||||
|
||||
/// Pure-Dart cryptographic helpers for the secure account-sharing flow.
|
||||
///
|
||||
/// Protocol:
|
||||
/// Receiver generates an X25519 key pair with 20-minute lifetime and shows
|
||||
/// its public key as a QR code. The sender scans that QR, encrypts the
|
||||
/// selected account(s) using ECIES (X25519-ECDH + HKDF-SHA256 + AES-256-GCM)
|
||||
/// and shows the encrypted payload as a QR code. The receiver scans that QR,
|
||||
/// looks up the private key by the embedded key-ID, and decrypts.
|
||||
class ShareEncryptionService {
|
||||
static final _x25519 = X25519();
|
||||
static final _aesGcm = AesGcm.with256bits();
|
||||
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||
static final _rng = Random.secure();
|
||||
|
||||
// ── Key generation ──────────────────────────────────────────────────────────
|
||||
|
||||
static Future<ShareKeyMaterial> generateKeyPair() async {
|
||||
final keyId = Uint8List(_keyIdLen);
|
||||
for (var i = 0; i < _keyIdLen; i++) {
|
||||
keyId[i] = _rng.nextInt(256);
|
||||
}
|
||||
|
||||
final keyPair = await _x25519.newKeyPair();
|
||||
final pub = await keyPair.extractPublicKey();
|
||||
final priv = await keyPair.extractPrivateKeyBytes();
|
||||
|
||||
return ShareKeyMaterial(
|
||||
keyId: keyId,
|
||||
publicKeyBytes: Uint8List.fromList(pub.bytes),
|
||||
privateKeyBytes: Uint8List.fromList(priv),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public-key QR encoding / parsing ────────────────────────────────────────
|
||||
|
||||
/// Encodes the receiver's public key as a QR-code string.
|
||||
///
|
||||
/// Format: `sharedinbox.de:pubkey:v1:<base64(keyId[16] || pubKey[32])>`
|
||||
static String encodePublicKeyQr(Uint8List keyId, Uint8List publicKeyBytes) {
|
||||
assert(keyId.length == _keyIdLen);
|
||||
assert(publicKeyBytes.length == _pubKeyLen);
|
||||
final data = Uint8List(_keyIdLen + _pubKeyLen)
|
||||
..setAll(0, keyId)
|
||||
..setAll(_keyIdLen, publicKeyBytes);
|
||||
return '$_pubKeyPrefix${base64.encode(data)}';
|
||||
}
|
||||
|
||||
/// Parses a public-key QR string. Returns null if the format is invalid.
|
||||
static ({Uint8List keyId, Uint8List publicKeyBytes})? parsePublicKeyQr(
|
||||
String s,
|
||||
) {
|
||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||
try {
|
||||
final data =
|
||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||
return (
|
||||
keyId: data.sublist(0, _keyIdLen),
|
||||
publicKeyBytes: data.sublist(_keyIdLen),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Encryption ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Encrypts [accounts] for the given recipient key pair using ECIES.
|
||||
///
|
||||
/// Returns the QR-code string to show on the sender device.
|
||||
///
|
||||
/// Wire format (base64-encoded):
|
||||
/// keyId[16] || ephPubKey[32] || nonce[12] || ciphertext || mac[16]
|
||||
static Future<String> encryptAccounts({
|
||||
required Uint8List recipientKeyId,
|
||||
required Uint8List recipientPublicKeyBytes,
|
||||
required List<AccountPayload> accounts,
|
||||
}) async {
|
||||
// Build plaintext JSON.
|
||||
final plaintext = utf8.encode(
|
||||
jsonEncode({
|
||||
'v': 2,
|
||||
'issuedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'accounts': accounts
|
||||
.map((a) => {'account': a.accountJson, 'password': a.password})
|
||||
.toList(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Ephemeral sender key pair for forward-secrecy.
|
||||
final ephKeyPair = await _x25519.newKeyPair();
|
||||
final ephPub = await ephKeyPair.extractPublicKey();
|
||||
|
||||
// ECDH: shared secret = X25519(ephPriv, recipientPub).
|
||||
final sharedSecret = await _x25519.sharedSecretKey(
|
||||
keyPair: ephKeyPair,
|
||||
remotePublicKey: SimplePublicKey(
|
||||
recipientPublicKeyBytes,
|
||||
type: KeyPairType.x25519,
|
||||
),
|
||||
);
|
||||
|
||||
// Derive AES key via HKDF-SHA256.
|
||||
final aesKey = await _hkdf.deriveKey(
|
||||
secretKey: sharedSecret,
|
||||
nonce: recipientKeyId,
|
||||
info: utf8.encode('sharedinbox-account-transfer'),
|
||||
);
|
||||
|
||||
// Encrypt with AES-256-GCM.
|
||||
final nonce = Uint8List(_nonceLen);
|
||||
for (var i = 0; i < _nonceLen; i++) {
|
||||
nonce[i] = _rng.nextInt(256);
|
||||
}
|
||||
|
||||
final box = await _aesGcm.encrypt(
|
||||
plaintext,
|
||||
secretKey: aesKey,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
// Pack wire format.
|
||||
final ephPubBytes = Uint8List.fromList(ephPub.bytes);
|
||||
final cipherBytes = Uint8List.fromList(box.cipherText);
|
||||
final macBytes = Uint8List.fromList(box.mac.bytes);
|
||||
|
||||
final out = Uint8List(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
||||
)
|
||||
..setAll(0, recipientKeyId)
|
||||
..setAll(_keyIdLen, ephPubBytes)
|
||||
..setAll(_keyIdLen + _pubKeyLen, nonce)
|
||||
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
|
||||
..setAll(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
|
||||
macBytes,
|
||||
);
|
||||
|
||||
return '$_encAccountsPrefix${base64.encode(out)}';
|
||||
}
|
||||
|
||||
// ── Decryption ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parses and decrypts an encrypted-accounts QR string.
|
||||
///
|
||||
/// Throws [FormatException] if the format is invalid.
|
||||
/// Throws [SecretBoxAuthenticationError] if authentication fails (tampered).
|
||||
static Future<List<AccountPayload>> decryptAccounts({
|
||||
required String qrString,
|
||||
required Uint8List privateKeyBytes,
|
||||
required Uint8List publicKeyBytes,
|
||||
required Uint8List keyId,
|
||||
}) async {
|
||||
if (!qrString.startsWith(_encAccountsPrefix)) {
|
||||
throw const FormatException('Not an encrypted-accounts QR code');
|
||||
}
|
||||
|
||||
final Uint8List data;
|
||||
try {
|
||||
data = Uint8List.fromList(
|
||||
base64.decode(qrString.substring(_encAccountsPrefix.length)),
|
||||
);
|
||||
} catch (_) {
|
||||
throw const FormatException('Invalid base64 in encrypted-accounts QR');
|
||||
}
|
||||
|
||||
// Minimum: keyId + ephPubKey + nonce + mac (no ciphertext is valid but odd).
|
||||
if (data.length < _keyIdLen + _pubKeyLen + _nonceLen + _macLen) {
|
||||
throw const FormatException('Encrypted-accounts payload too short');
|
||||
}
|
||||
|
||||
final embeddedKeyId = data.sublist(0, _keyIdLen);
|
||||
// Verify that this payload was encrypted for the right key pair.
|
||||
for (var i = 0; i < _keyIdLen; i++) {
|
||||
if (embeddedKeyId[i] != keyId[i]) {
|
||||
throw const FormatException(
|
||||
'Key ID mismatch — please scan a fresh public-key QR code',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final ephPubBytes = data.sublist(_keyIdLen, _keyIdLen + _pubKeyLen);
|
||||
final nonce = data.sublist(
|
||||
_keyIdLen + _pubKeyLen,
|
||||
_keyIdLen + _pubKeyLen + _nonceLen,
|
||||
);
|
||||
final cipherText = data.sublist(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen,
|
||||
data.length - _macLen,
|
||||
);
|
||||
final mac = data.sublist(data.length - _macLen);
|
||||
|
||||
// Reconstruct key pair.
|
||||
final keyPair = SimpleKeyPairData(
|
||||
privateKeyBytes,
|
||||
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519),
|
||||
type: KeyPairType.x25519,
|
||||
);
|
||||
|
||||
// ECDH.
|
||||
final sharedSecret = await _x25519.sharedSecretKey(
|
||||
keyPair: keyPair,
|
||||
remotePublicKey: SimplePublicKey(ephPubBytes, type: KeyPairType.x25519),
|
||||
);
|
||||
|
||||
// Re-derive AES key.
|
||||
final aesKey = await _hkdf.deriveKey(
|
||||
secretKey: sharedSecret,
|
||||
nonce: keyId,
|
||||
info: utf8.encode('sharedinbox-account-transfer'),
|
||||
);
|
||||
|
||||
// Decrypt — throws SecretBoxAuthenticationError if tampered.
|
||||
final plaintext = await _aesGcm.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||
secretKey: aesKey,
|
||||
);
|
||||
|
||||
// Parse JSON.
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw const FormatException('Decrypted payload is not valid JSON');
|
||||
}
|
||||
|
||||
if ((json['v'] as int?) != 2) {
|
||||
throw const FormatException('Unsupported encrypted-accounts version');
|
||||
}
|
||||
|
||||
// Verify issuedAt is within 20 minutes.
|
||||
final issuedAtRaw = json['issuedAt'] as String?;
|
||||
if (issuedAtRaw != null) {
|
||||
final issuedAt = DateTime.tryParse(issuedAtRaw);
|
||||
if (issuedAt != null) {
|
||||
final age = DateTime.now().toUtc().difference(issuedAt.toUtc());
|
||||
if (age.abs() > const Duration(minutes: 20)) {
|
||||
throw const FormatException(
|
||||
'The encrypted payload has expired (older than 20 minutes)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final rawAccounts = json['accounts'] as List<dynamic>;
|
||||
return rawAccounts.map((entry) {
|
||||
final m = entry as Map<String, dynamic>;
|
||||
return AccountPayload(
|
||||
accountJson: m['account'] as Map<String, dynamic>,
|
||||
password: m['password'] as String,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(removed.id));
|
||||
}
|
||||
state = newList;
|
||||
await ref.read(undoRepositoryProvider).saveAction(action);
|
||||
unawaited(_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 {
|
||||
@@ -46,19 +45,19 @@ class UndoService extends Notifier<List<UndoAction>> {
|
||||
final UndoAction action;
|
||||
if (actionId == null) {
|
||||
action = state.last;
|
||||
state = state.sublist(0, state.length - 1);
|
||||
} else {
|
||||
try {
|
||||
action = state.firstWhere((a) => a.id == actionId);
|
||||
state = state.where((a) => a.id != actionId).toList();
|
||||
} catch (e) {
|
||||
return; // Action not found
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the original entry in state and DB so the user can see what
|
||||
// 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.
|
||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(action.id));
|
||||
|
||||
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).
|
||||
@@ -71,22 +70,10 @@ class UndoService extends Notifier<List<UndoAction>> {
|
||||
? null
|
||||
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
||||
|
||||
// 2. Resolve the current DB row for the email.
|
||||
// For IMAP, after a server-applied move the email gets a new UID, so
|
||||
// the original id ('accountId:oldUid') no longer exists. Look it up by
|
||||
// Message-ID so we use the correct UID in the pending change.
|
||||
var currentEmail = await repo.getEmail(id);
|
||||
if (currentEmail == null && original?.messageId != null) {
|
||||
currentEmail = await repo.findEmailByMessageId(
|
||||
action.accountId,
|
||||
original!.messageId!,
|
||||
);
|
||||
}
|
||||
final currentId = currentEmail?.id ?? id;
|
||||
|
||||
// 3. If the row is absent (hard delete or UID changed after sync),
|
||||
// restore it from the saved snapshot so moveEmail can find it.
|
||||
if (currentEmail == null && original != null) {
|
||||
// 2. If row is missing (hard delete), restore it first.
|
||||
// We restore it at its CURRENT state (where it is on the server,
|
||||
// or where it was moving to).
|
||||
if (original != null) {
|
||||
final currentPath = cancelled
|
||||
? action.sourceMailboxPath
|
||||
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
||||
@@ -95,40 +82,19 @@ class UndoService extends Notifier<List<UndoAction>> {
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Move it back to source.
|
||||
// 3. Move it back to source.
|
||||
// This updates local DB optimistically and (if not cancelled) enqueues
|
||||
// a reverse move on the server using the correct UID.
|
||||
await repo.moveEmail(currentId, action.sourceMailboxPath);
|
||||
// a reverse move on the server.
|
||||
await repo.moveEmail(id, action.sourceMailboxPath);
|
||||
|
||||
if (cancelled) {
|
||||
// 5. If we successfully cancelled the original, the reverse move
|
||||
// 4. If we successfully cancelled the original, the reverse move
|
||||
// we just enqueued is redundant.
|
||||
await repo.cancelPendingChange(currentId, 'move');
|
||||
await repo.cancelPendingChange(id, 'move');
|
||||
}
|
||||
} catch (e) {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
// Add a reverse action so the undo log always retains a record and the
|
||||
// user can re-apply the original operation. sourceMailboxPath on the
|
||||
// inverse is the original destination (e.g. Trash) so that undoing the
|
||||
// inverse moves emails back there; destinationMailboxPath records where
|
||||
// they are now (the original source, e.g. INBOX).
|
||||
final inverseDest = action.destinationMailboxPath;
|
||||
if (inverseDest != null) {
|
||||
await pushAction(
|
||||
UndoAction(
|
||||
id: '${action.id}-inv',
|
||||
accountId: action.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: action.emailIds,
|
||||
sourceMailboxPath: inverseDest,
|
||||
destinationMailboxPath: action.sourceMailboxPath,
|
||||
originalEmails: action.originalEmails,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const _kAppVersion = String.fromEnvironment('GIT_HASH');
|
||||
const _kLatestJsonUrl = 'https://sharedinbox.de/latest.json';
|
||||
|
||||
class UpdateInfo {
|
||||
const UpdateInfo({required this.latestVersion, required this.downloadUrl});
|
||||
|
||||
final String latestVersion;
|
||||
final String downloadUrl;
|
||||
}
|
||||
|
||||
/// Returns an [UpdateInfo] when a newer Linux or Windows version is available,
|
||||
/// or null if the app is up to date, the version is unknown, or the platform
|
||||
/// is not a supported desktop.
|
||||
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||
final platformKey = Platform.isLinux
|
||||
? 'linux'
|
||||
: Platform.isWindows
|
||||
? 'windows'
|
||||
: null;
|
||||
if (platformKey == null || _kAppVersion.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse(_kLatestJsonUrl))
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (resp.statusCode != 200) return null;
|
||||
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final latest = json['version'] as String?;
|
||||
final url = json[platformKey] as String?;
|
||||
if (latest == null || url == null) return null;
|
||||
if (latest == _kAppVersion) return null;
|
||||
return UpdateInfo(latestVersion: latest, downloadUrl: url);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
sealed class SieveAction {}
|
||||
|
||||
final class FileIntoAction extends SieveAction {
|
||||
FileIntoAction(this.folder);
|
||||
final String folder;
|
||||
}
|
||||
|
||||
final class KeepAction extends SieveAction {}
|
||||
|
||||
final class DiscardAction extends SieveAction {}
|
||||
|
||||
final class MarkAsSeenAction extends SieveAction {}
|
||||
|
||||
final class FlagAction extends SieveAction {
|
||||
FlagAction(this.flags);
|
||||
final List<String> flags;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
sealed class SieveCondition {}
|
||||
|
||||
final class HeaderCondition extends SieveCondition {
|
||||
HeaderCondition(this.headers, this.matchType, this.keyList);
|
||||
final List<String> headers;
|
||||
final String matchType; // ':contains', ':is', ':matches'
|
||||
final List<String> keyList;
|
||||
}
|
||||
|
||||
final class SizeCondition extends SieveCondition {
|
||||
SizeCondition(this.comparison, this.bytes);
|
||||
final String comparison; // ':over' or ':under'
|
||||
final int bytes;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||
|
||||
/// A lightweight email representation used by [SieveInterpreter].
|
||||
/// Header names are lower-cased.
|
||||
class SieveEmailContext {
|
||||
const SieveEmailContext({required this.headers, this.sizeBytes = 0});
|
||||
|
||||
final Map<String, List<String>> headers;
|
||||
final int sizeBytes;
|
||||
|
||||
List<String> getHeader(String name) =>
|
||||
headers[name.toLowerCase()] ?? const [];
|
||||
}
|
||||
|
||||
/// Tracks the outcome of running a Sieve script against one email.
|
||||
class SieveExecutionContext {
|
||||
bool isCancelled = false;
|
||||
Set<String> targetFolders = {};
|
||||
Set<String> flagsToAdd = {};
|
||||
bool keepInInbox = true;
|
||||
}
|
||||
|
||||
/// Evaluates a compiled list of [SieveRule]s against a [SieveEmailContext].
|
||||
class SieveInterpreter {
|
||||
/// Executes [rules] and returns the resulting [SieveExecutionContext].
|
||||
///
|
||||
/// Rules produced by [SieveParser] may carry a [SieveRule.branchGroupId]
|
||||
/// to represent if/elsif/else chains; at most one branch per group fires.
|
||||
SieveExecutionContext execute(
|
||||
List<SieveRule> rules,
|
||||
SieveEmailContext email,
|
||||
) {
|
||||
final ctx = SieveExecutionContext();
|
||||
final firedGroups = <int>{};
|
||||
|
||||
for (final rule in rules) {
|
||||
if (ctx.isCancelled) break;
|
||||
|
||||
final groupId = rule.branchGroupId;
|
||||
if (groupId != null && firedGroups.contains(groupId)) continue;
|
||||
|
||||
bool matches;
|
||||
if (rule.isElseBranch) {
|
||||
matches = true; // else fires unconditionally (group not yet consumed)
|
||||
} else {
|
||||
matches = _evaluateConditions(rule, email);
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
_applyActions(rule.actions, ctx);
|
||||
if (groupId != null) firedGroups.add(groupId);
|
||||
if (ctx.isCancelled) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Implicit keep: if no fileinto/discard was reached, email stays in inbox.
|
||||
return ctx;
|
||||
}
|
||||
|
||||
bool _evaluateConditions(SieveRule rule, SieveEmailContext email) {
|
||||
if (rule.conditions.isEmpty) return true;
|
||||
return switch (rule.joinType) {
|
||||
'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
|
||||
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
|
||||
_ => rule.conditions.length == 1 &&
|
||||
_evalCondition(rule.conditions.first, email),
|
||||
};
|
||||
}
|
||||
|
||||
bool _evalCondition(SieveCondition cond, SieveEmailContext email) {
|
||||
return switch (cond) {
|
||||
final HeaderCondition c => _evalHeader(c, email),
|
||||
final SizeCondition c => _evalSize(c, email),
|
||||
};
|
||||
}
|
||||
|
||||
bool _evalHeader(HeaderCondition cond, SieveEmailContext email) {
|
||||
for (final header in cond.headers) {
|
||||
final values = email.getHeader(header);
|
||||
for (final value in values) {
|
||||
for (final key in cond.keyList) {
|
||||
if (_matchString(value, cond.matchType, key)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _evalSize(SizeCondition cond, SieveEmailContext email) {
|
||||
return switch (cond.comparison) {
|
||||
':over' => email.sizeBytes > cond.bytes,
|
||||
':under' => email.sizeBytes < cond.bytes,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _matchString(String value, String matchType, String key) {
|
||||
final v = value.toLowerCase();
|
||||
final k = key.toLowerCase();
|
||||
return switch (matchType) {
|
||||
':contains' => k.isEmpty || v.contains(k),
|
||||
':is' => v == k,
|
||||
':matches' => _globMatch(v, k),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _globMatch(String value, String pattern) {
|
||||
final regexStr =
|
||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$').hasMatch(value);
|
||||
}
|
||||
|
||||
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||
for (final action in actions) {
|
||||
switch (action) {
|
||||
case final FileIntoAction a:
|
||||
ctx.targetFolders.add(a.folder);
|
||||
ctx.keepInInbox = false;
|
||||
case DiscardAction():
|
||||
ctx.isCancelled = true;
|
||||
ctx.keepInInbox = false;
|
||||
return;
|
||||
case KeepAction():
|
||||
ctx.keepInInbox = true;
|
||||
case MarkAsSeenAction():
|
||||
ctx.flagsToAdd.add(r'\Seen');
|
||||
case final FlagAction a:
|
||||
ctx.flagsToAdd.addAll(a.flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||
|
||||
/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s.
|
||||
///
|
||||
/// Supported commands: require, if, elsif, else, fileinto, keep, discard,
|
||||
/// flag, setflag, addflag, stop.
|
||||
/// Supported tests: header, address, size, exists, allof, anyof, not, true.
|
||||
/// Supported match types: :contains, :is, :matches.
|
||||
class SieveParser {
|
||||
List<SieveRule> parse(String script) {
|
||||
final scanner = _Scanner(script);
|
||||
final rules = <SieveRule>[];
|
||||
_parseStatements(scanner, rules);
|
||||
return rules;
|
||||
}
|
||||
|
||||
void _parseStatements(_Scanner s, List<SieveRule> out) {
|
||||
while (!s.isAtEnd) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.isAtEnd) break;
|
||||
|
||||
final word = s.peekWord();
|
||||
if (word == null) break;
|
||||
|
||||
if (word == 'require') {
|
||||
_parseRequire(s);
|
||||
} else if (word == 'if') {
|
||||
_parseIf(s, out);
|
||||
} else if (word == 'elsif' || word == 'else') {
|
||||
// Reached by _parseIf, should not appear at top level.
|
||||
break;
|
||||
} else if (word == '}') {
|
||||
break;
|
||||
} else {
|
||||
final action = _tryParseAction(s);
|
||||
if (action != null) {
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: 'single',
|
||||
conditions: const [],
|
||||
actions: [action],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
s.skipToNextSemicolon();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _parseRequire(_Scanner s) {
|
||||
s.expectWord('require');
|
||||
s.skipWhitespaceAndComments();
|
||||
_parseStringOrList(s); // discard capability list
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
}
|
||||
|
||||
// Monotonically increasing id shared per parse run, threaded via closure.
|
||||
int _groupCounter = 0;
|
||||
|
||||
void _parseIf(_Scanner s, List<SieveRule> out) {
|
||||
final groupId = ++_groupCounter;
|
||||
|
||||
s.expectWord('if');
|
||||
s.skipWhitespaceAndComments();
|
||||
final (joinType, conditions) = _parseTest(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
final ifActions = _parseBlock(s);
|
||||
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: joinType,
|
||||
conditions: conditions,
|
||||
actions: ifActions,
|
||||
branchGroupId: groupId,
|
||||
),
|
||||
);
|
||||
|
||||
// Parse zero or more elsif branches.
|
||||
while (true) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peekWord() != 'elsif') break;
|
||||
s.expectWord('elsif');
|
||||
s.skipWhitespaceAndComments();
|
||||
final (ej, ec) = _parseTest(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
final elsifActions = _parseBlock(s);
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: ej,
|
||||
conditions: ec,
|
||||
actions: elsifActions,
|
||||
branchGroupId: groupId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Optional else branch.
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peekWord() == 'else') {
|
||||
s.expectWord('else');
|
||||
s.skipWhitespaceAndComments();
|
||||
final elseActions = _parseBlock(s);
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: 'single',
|
||||
conditions: const [],
|
||||
actions: elseActions,
|
||||
branchGroupId: groupId,
|
||||
isElseBranch: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<SieveAction> _parseBlock(_Scanner s) {
|
||||
s.expectChar('{');
|
||||
final blockRules = <SieveRule>[];
|
||||
_parseStatements(s, blockRules);
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar('}');
|
||||
return blockRules.expand((r) => r.actions).toList();
|
||||
}
|
||||
|
||||
/// Returns (joinType, conditions).
|
||||
(String, List<SieveCondition>) _parseTest(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final word = s.peekWord();
|
||||
|
||||
if (word == 'allof' || word == 'anyof') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar('(');
|
||||
final conditions = <SieveCondition>[];
|
||||
while (true) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ')') break;
|
||||
final (_, conds) = _parseTest(s);
|
||||
conditions.addAll(conds);
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ',') {
|
||||
s.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(')');
|
||||
return (word!, conditions);
|
||||
}
|
||||
|
||||
final cond = _parseSingleTest(s);
|
||||
return ('single', cond != null ? [cond] : []);
|
||||
}
|
||||
|
||||
SieveCondition? _parseSingleTest(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
|
||||
if (word == 'not') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
// Negation is not represented in the flat rule model; the caller
|
||||
// should handle the negated condition separately. For now we parse
|
||||
// and return the inner condition unchanged (best-effort for this subset).
|
||||
return _parseSingleTest(s);
|
||||
}
|
||||
|
||||
if (word == 'true') {
|
||||
s.readWord();
|
||||
return null; // no condition = always matches
|
||||
}
|
||||
|
||||
if (word == 'header' || word == 'address') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final matchType = _parseMatchType(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
// Consume optional :comparator "..." tagged argument.
|
||||
if (s.peekTaggedArg() == ':comparator') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
_parseStringOrList(s); // discard comparator value
|
||||
s.skipWhitespaceAndComments();
|
||||
}
|
||||
final headers = _parseStringOrList(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
final keys = _parseStringOrList(s);
|
||||
return HeaderCondition(headers, matchType, keys);
|
||||
}
|
||||
|
||||
if (word == 'exists') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final headers = _parseStringOrList(s);
|
||||
// Represent exists as :contains "" so any non-empty value matches.
|
||||
return HeaderCondition(headers, ':contains', const ['']);
|
||||
}
|
||||
|
||||
if (word == 'size') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final comp = s.readTaggedArg(); // :over or :under
|
||||
s.skipWhitespaceAndComments();
|
||||
final bytes = _parseSizeNumber(s);
|
||||
return SizeCondition(comp, bytes);
|
||||
}
|
||||
|
||||
// Unknown test — skip to closing paren or brace.
|
||||
s.readWord();
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parseMatchType(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final tag = s.peekTaggedArg();
|
||||
if (tag == ':contains' || tag == ':is' || tag == ':matches') {
|
||||
s.readWord();
|
||||
return tag!;
|
||||
}
|
||||
// Default per RFC 5228 is :is.
|
||||
return ':is';
|
||||
}
|
||||
|
||||
List<String> _parseStringOrList(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == '[') {
|
||||
s.advance(); // consume '['
|
||||
final items = <String>[];
|
||||
while (true) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ']') {
|
||||
s.advance();
|
||||
break;
|
||||
}
|
||||
items.add(_parseString(s));
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ',') {
|
||||
s.advance();
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
return [_parseString(s)];
|
||||
}
|
||||
|
||||
String _parseString(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == '"') {
|
||||
return s.readQuotedString();
|
||||
}
|
||||
// Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2)
|
||||
if (s.peekWord()?.toLowerCase() == 'text:') {
|
||||
return s.readTextBlock();
|
||||
}
|
||||
throw SieveParseException(
|
||||
'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"',
|
||||
);
|
||||
}
|
||||
|
||||
int _parseSizeNumber(_Scanner s) {
|
||||
final digits = s.readDigits();
|
||||
final value = int.parse(digits);
|
||||
final unit = s.peekSizeUnit();
|
||||
if (unit != null) {
|
||||
s.advance();
|
||||
return switch (unit.toUpperCase()) {
|
||||
'K' => value * 1024,
|
||||
'M' => value * 1024 * 1024,
|
||||
'G' => value * 1024 * 1024 * 1024,
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
SieveAction? _tryParseAction(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
|
||||
if (word == 'fileinto') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final folder = _parseString(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return FileIntoAction(folder);
|
||||
}
|
||||
if (word == 'keep') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return KeepAction();
|
||||
}
|
||||
if (word == 'discard') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return DiscardAction();
|
||||
}
|
||||
if (word == 'stop') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return KeepAction(); // stop with no prior action = implicit keep
|
||||
}
|
||||
if (word == 'flag' || word == 'setflag' || word == 'addflag') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
// Optional variable name (string arg before the flag list).
|
||||
final peek = s.peek();
|
||||
List<String> flags;
|
||||
if (peek == '"') {
|
||||
final first = _parseString(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == '[' || s.peek() == '"') {
|
||||
// first was the variable name, next is the flag list
|
||||
flags = _parseStringOrList(s);
|
||||
} else {
|
||||
flags = [first];
|
||||
}
|
||||
} else {
|
||||
flags = _parseStringOrList(s);
|
||||
}
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
if (flags.any(
|
||||
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||
)) {
|
||||
return MarkAsSeenAction();
|
||||
}
|
||||
return FlagAction(flags);
|
||||
}
|
||||
if (word == 'mark') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return MarkAsSeenAction();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SieveParseException implements Exception {
|
||||
SieveParseException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'SieveParseException: $message';
|
||||
}
|
||||
|
||||
class _Scanner {
|
||||
_Scanner(this._src);
|
||||
|
||||
final String _src;
|
||||
int _pos = 0;
|
||||
|
||||
int get position => _pos;
|
||||
bool get isAtEnd => _pos >= _src.length;
|
||||
String get remaining => _pos < _src.length ? _src.substring(_pos) : '';
|
||||
|
||||
String? peek() {
|
||||
if (isAtEnd) return null;
|
||||
return _src[_pos];
|
||||
}
|
||||
|
||||
String advance() {
|
||||
if (isAtEnd) throw SieveParseException('Unexpected end of input');
|
||||
return _src[_pos++];
|
||||
}
|
||||
|
||||
void skipWhitespaceAndComments() {
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||
_pos++;
|
||||
} else if (ch == '#') {
|
||||
// Line comment — skip to end of line.
|
||||
while (!isAtEnd && _src[_pos] != '\n') {
|
||||
_pos++;
|
||||
}
|
||||
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||
// Block comment.
|
||||
_pos += 2;
|
||||
while (_pos + 1 < _src.length) {
|
||||
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||
_pos += 2;
|
||||
break;
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Peeks at the next word-like token (letters/digits/underscores/colons for
|
||||
/// tagged args, and special single-char tokens like `{`, `}`, `;`).
|
||||
String? peekWord() {
|
||||
if (isAtEnd) return null;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) return ch;
|
||||
if (ch == ':') {
|
||||
// Tagged arg like :contains
|
||||
final start = _pos;
|
||||
var end = _pos + 1;
|
||||
while (end < _src.length && _isWordChar(_src[end])) {
|
||||
end++;
|
||||
}
|
||||
return _src.substring(start, end).toLowerCase();
|
||||
}
|
||||
if (_isWordChar(ch)) {
|
||||
final start = _pos;
|
||||
var end = _pos + 1;
|
||||
while (
|
||||
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
|
||||
// Include trailing colon for "text:" multiline token.
|
||||
if (_src[end] == ':') {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
return _src.substring(start, end).toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String readWord() {
|
||||
final start = _pos;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) {
|
||||
_pos++;
|
||||
return ch;
|
||||
}
|
||||
if (ch == ':') {
|
||||
_pos++;
|
||||
while (!isAtEnd && _isWordChar(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) {
|
||||
if (_src[_pos] == ':') {
|
||||
_pos++;
|
||||
break;
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
return _src.substring(start, _pos).toLowerCase();
|
||||
}
|
||||
|
||||
String? peekTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return peekWord();
|
||||
return null;
|
||||
}
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw SieveParseException(
|
||||
'Expected tagged argument at position $_pos',
|
||||
);
|
||||
}
|
||||
|
||||
String? peekSizeUnit() {
|
||||
if (isAtEnd) return null;
|
||||
final ch = _src[_pos].toUpperCase();
|
||||
if (ch == 'K' || ch == 'M' || ch == 'G') return ch;
|
||||
return null;
|
||||
}
|
||||
|
||||
String readDigits() {
|
||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||
throw SieveParseException(
|
||||
'Expected number at position $_pos',
|
||||
);
|
||||
}
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
return _src.substring(start, _pos);
|
||||
}
|
||||
|
||||
String readQuotedString() {
|
||||
if (_src[_pos] != '"') {
|
||||
throw SieveParseException(
|
||||
'Expected " at position $_pos',
|
||||
);
|
||||
}
|
||||
_pos++; // skip opening quote
|
||||
final buf = StringBuffer();
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == '"') {
|
||||
_pos++;
|
||||
return buf.toString();
|
||||
}
|
||||
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||
_pos++;
|
||||
buf.write(_src[_pos]);
|
||||
_pos++;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
throw SieveParseException('Unterminated string');
|
||||
}
|
||||
|
||||
/// Parses a `text:` multi-line block (RFC 5228 §2.4.2).
|
||||
/// Format: `text:\r\n<lines>\r\n.\r\n`
|
||||
String readTextBlock() {
|
||||
// Consume "text:"
|
||||
while (!isAtEnd && _src[_pos] != ':') {
|
||||
_pos++;
|
||||
}
|
||||
if (!isAtEnd) _pos++; // skip ':'
|
||||
// Skip optional whitespace then newline.
|
||||
while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) {
|
||||
_pos++;
|
||||
}
|
||||
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||
final buf = StringBuffer();
|
||||
while (!isAtEnd) {
|
||||
// Check for terminator: a lone "." on its own line.
|
||||
if (_src[_pos] == '.' &&
|
||||
(_pos + 1 >= _src.length ||
|
||||
_src[_pos + 1] == '\r' ||
|
||||
_src[_pos + 1] == '\n')) {
|
||||
_pos++;
|
||||
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||
break;
|
||||
}
|
||||
buf.write(_src[_pos]);
|
||||
_pos++;
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
void expectChar(String ch) {
|
||||
skipWhitespaceAndComments();
|
||||
if (isAtEnd || _src[_pos] != ch) {
|
||||
throw SieveParseException(
|
||||
'Expected "$ch" at position $_pos, got '
|
||||
'"${isAtEnd ? "EOF" : _src[_pos]}"',
|
||||
);
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
|
||||
void expectWord(String word) {
|
||||
skipWhitespaceAndComments();
|
||||
final got = readWord();
|
||||
if (got.toLowerCase() != word.toLowerCase()) {
|
||||
throw SieveParseException(
|
||||
'Expected "$word" at position $_pos, got "$got"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void skipToNextSemicolon() {
|
||||
while (!isAtEnd && _src[_pos] != ';') {
|
||||
_pos++;
|
||||
}
|
||||
if (!isAtEnd) _pos++; // skip ';'
|
||||
}
|
||||
|
||||
static bool _isWordChar(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return (c >= 0x41 && c <= 0x5A) || // A-Z
|
||||
(c >= 0x61 && c <= 0x7A) || // a-z
|
||||
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||
c == 0x5F || // _
|
||||
c == 0x2D; // -
|
||||
}
|
||||
|
||||
static bool _isDigit(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return c >= 0x30 && c <= 0x39;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
|
||||
class SieveRule {
|
||||
const SieveRule({
|
||||
required this.joinType,
|
||||
required this.conditions,
|
||||
required this.actions,
|
||||
this.branchGroupId,
|
||||
this.isElseBranch = false,
|
||||
});
|
||||
|
||||
// 'allof', 'anyof', or 'single'
|
||||
final String joinType;
|
||||
final List<SieveCondition> conditions;
|
||||
final List<SieveAction> actions;
|
||||
// Non-null groups this rule into an if/elsif/else chain.
|
||||
final int? branchGroupId;
|
||||
// True for the unconditional else branch.
|
||||
final bool isElseBranch;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -202,7 +201,6 @@ class _AccountSync implements _SyncLoop {
|
||||
bool _running = false;
|
||||
int _backoffSeconds = 5;
|
||||
Completer<void>? _stopSignal;
|
||||
Timer? _waitTimer;
|
||||
|
||||
@override
|
||||
void start() {
|
||||
@@ -260,8 +258,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 +293,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') ||
|
||||
@@ -308,16 +303,11 @@ class _AccountSync implements _SyncLoop {
|
||||
Future<void> _waitSeconds(int seconds) async {
|
||||
if (!_running) return;
|
||||
_stopSignal = Completer<void>();
|
||||
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||
});
|
||||
try {
|
||||
await _stopSignal!.future;
|
||||
} finally {
|
||||
_waitTimer?.cancel();
|
||||
_waitTimer = null;
|
||||
_stopSignal = null;
|
||||
}
|
||||
await Future.any([
|
||||
Future.delayed(Duration(seconds: seconds)),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
_stopSignal = null;
|
||||
}
|
||||
|
||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||
@@ -351,7 +341,6 @@ class _AccountSync implements _SyncLoop {
|
||||
final mailboxStats = <MailboxSyncStats>[];
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!_running) break;
|
||||
final mailboxStart = DateTime.now();
|
||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||
emailResult += r;
|
||||
mailboxStats.add(
|
||||
@@ -360,11 +349,9 @@ class _AccountSync implements _SyncLoop {
|
||||
fetched: r.fetched,
|
||||
skipped: r.skipped,
|
||||
bytesTransferred: r.bytesTransferred,
|
||||
duration: DateTime.now().difference(mailboxStart),
|
||||
),
|
||||
);
|
||||
}
|
||||
await _emails.applySieveRules(account.id);
|
||||
return _SyncStats(
|
||||
emailsFetched: emailResult.fetched,
|
||||
emailsSkipped: emailResult.skipped,
|
||||
@@ -407,16 +394,11 @@ class _AccountSync implements _SyncLoop {
|
||||
|
||||
// Cap IDLE at 25 minutes (RFC 2177). Also wakes up when stop() is
|
||||
// called or a new message / expunge event arrives.
|
||||
final idleTimer = Timer(const Duration(minutes: 25), () {
|
||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||
_stopSignal!.complete();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Future.any([newMessageCompleter.future, _stopSignal!.future]);
|
||||
} finally {
|
||||
idleTimer.cancel();
|
||||
}
|
||||
await Future.any([
|
||||
newMessageCompleter.future,
|
||||
Future.delayed(const Duration(minutes: 25)),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
|
||||
await client.idleDone();
|
||||
await sub.cancel();
|
||||
@@ -457,7 +439,6 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
bool _running = false;
|
||||
int _backoffSeconds = 5;
|
||||
Completer<void>? _stopSignal;
|
||||
Timer? _waitTimer;
|
||||
|
||||
static const _pollInterval = Duration(seconds: 30);
|
||||
|
||||
@@ -515,8 +496,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 +531,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') ||
|
||||
@@ -564,16 +542,11 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
Future<void> _waitSeconds(int seconds) async {
|
||||
if (!_running) return;
|
||||
_stopSignal = Completer<void>();
|
||||
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||
});
|
||||
try {
|
||||
await _stopSignal!.future;
|
||||
} finally {
|
||||
_waitTimer?.cancel();
|
||||
_waitTimer = null;
|
||||
_stopSignal = null;
|
||||
}
|
||||
await Future.any([
|
||||
Future.delayed(Duration(seconds: seconds)),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
_stopSignal = null;
|
||||
}
|
||||
|
||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||
@@ -608,7 +581,6 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
final mailboxStats = <MailboxSyncStats>[];
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!_running) break;
|
||||
final mailboxStart = DateTime.now();
|
||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||
emailResult += r;
|
||||
mailboxStats.add(
|
||||
@@ -617,11 +589,9 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
fetched: r.fetched,
|
||||
skipped: r.skipped,
|
||||
bytesTransferred: r.bytesTransferred,
|
||||
duration: DateTime.now().difference(mailboxStart),
|
||||
),
|
||||
);
|
||||
}
|
||||
await _emails.applySieveRules(account.id);
|
||||
return _SyncStats(
|
||||
emailsFetched: emailResult.fetched,
|
||||
emailsSkipped: emailResult.skipped,
|
||||
@@ -648,16 +618,11 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
onError: (_) {},
|
||||
);
|
||||
|
||||
final pollTimer = Timer(_pollInterval, () {
|
||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||
_stopSignal!.complete();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Future.any([pushReady.future, _stopSignal!.future]);
|
||||
} finally {
|
||||
pollTimer.cancel();
|
||||
}
|
||||
await Future.any([
|
||||
pushReady.future,
|
||||
Future.delayed(_pollInterval),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
|
||||
await pushSub.cancel();
|
||||
_stopSignal = null;
|
||||
|
||||
@@ -5,8 +5,6 @@ import 'dart:io';
|
||||
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 +23,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();
|
||||
@@ -37,22 +32,14 @@ void callbackDispatcher() {
|
||||
}
|
||||
|
||||
Future<void> registerBackgroundSync() async {
|
||||
try {
|
||||
await Workmanager().initialize(callbackDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
_kTaskName,
|
||||
_kTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
);
|
||||
} on PlatformException {
|
||||
// WorkManager channel unavailable on this device; background sync disabled.
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; background sync disabled.
|
||||
} catch (_) {
|
||||
// Unexpected initialization failure; background sync disabled.
|
||||
}
|
||||
await Workmanager().initialize(callbackDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
_kTaskName,
|
||||
_kTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _doBackgroundSync() async {
|
||||
|
||||
@@ -50,7 +50,7 @@ class ReliabilityRunner {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runForAccount(String accountId, {bool force = false}) async {
|
||||
Future<void> _runForAccount(String accountId) async {
|
||||
try {
|
||||
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
||||
var totalMissingLocally = 0;
|
||||
@@ -59,7 +59,7 @@ class ReliabilityRunner {
|
||||
final details = <String, dynamic>{};
|
||||
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!force && !_running) break;
|
||||
if (!_running) break;
|
||||
final result = await _emails.verifySyncReliability(
|
||||
accountId,
|
||||
mailbox.path,
|
||||
@@ -103,14 +103,7 @@ class ReliabilityRunner {
|
||||
}
|
||||
|
||||
/// Forces a reliability check for all accounts immediately.
|
||||
///
|
||||
/// Works regardless of whether [start] has been called, so the UI can
|
||||
/// trigger a manual check at any time without depending on the periodic
|
||||
/// runner being active.
|
||||
Future<void> checkNow() async {
|
||||
final accounts = await _accounts.observeAccounts().first;
|
||||
for (final account in accounts) {
|
||||
await _runForAccount(account.id, force: true);
|
||||
}
|
||||
await _runAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
|
||||
/// Replaces `src="cid:..."` references in [html] with inline `data:` URIs
|
||||
/// by looking up each Content-ID in the MIME tree of [msg].
|
||||
///
|
||||
/// Emails with `multipart/related` often embed images this way. Without
|
||||
/// substitution the WebView shows broken image icons even after the full
|
||||
/// message has been downloaded.
|
||||
String injectInlineImages(String html, imap.MimeMessage msg) {
|
||||
final inlineParts = msg.findContentInfo(
|
||||
disposition: imap.ContentDisposition.inline,
|
||||
);
|
||||
if (inlineParts.isEmpty) return html;
|
||||
|
||||
var result = html;
|
||||
for (final info in inlineParts) {
|
||||
final cid = info.cid;
|
||||
if (cid == null || cid.isEmpty) continue;
|
||||
final bareCid = cid.startsWith('<') && cid.endsWith('>')
|
||||
? cid.substring(1, cid.length - 1)
|
||||
: cid;
|
||||
|
||||
final part = msg.getPart(info.fetchId);
|
||||
if (part == null) continue;
|
||||
final bytes = part.decodeContentBinary();
|
||||
if (bytes == null || bytes.isEmpty) continue;
|
||||
|
||||
final contentType =
|
||||
info.contentType?.mediaType.text ?? 'application/octet-stream';
|
||||
final dataUri = 'data:$contentType;base64,${base64.encode(bytes)}';
|
||||
|
||||
result = result
|
||||
.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'",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+10
-172
@@ -3,10 +3,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
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';
|
||||
|
||||
@@ -109,8 +107,6 @@ class EmailBodies extends Table {
|
||||
DateTimeColumn get cachedAt => dateTime().nullable()();
|
||||
// Added in schema v20: raw or parsed headers
|
||||
TextColumn get headersJson => text().nullable()();
|
||||
// Added in schema v28: serialised MimePart tree (JSON)
|
||||
TextColumn get mimeTreeJson => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {emailId};
|
||||
@@ -193,9 +189,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.
|
||||
@@ -209,8 +202,6 @@ class SyncLogMailboxes extends Table {
|
||||
IntColumn get fetched => integer().withDefault(const Constant(0))();
|
||||
IntColumn get skipped => integer().withDefault(const Constant(0))();
|
||||
IntColumn get bytesTransferred => integer().withDefault(const Constant(0))();
|
||||
// Added in schema v30: how long this mailbox took to sync, in milliseconds.
|
||||
IntColumn get durationMs => integer().nullable()();
|
||||
}
|
||||
|
||||
/// Stores the result of the periodic "ground truth" verification.
|
||||
@@ -243,42 +234,6 @@ class Drafts extends Table {
|
||||
TextColumn get imapServerId => text().nullable()();
|
||||
}
|
||||
|
||||
/// Ephemeral public/private key pair generated for secure account sharing.
|
||||
/// Expires after 20 minutes; used to decrypt an incoming encrypted-accounts QR.
|
||||
@DataClassName('ShareKeyRow')
|
||||
class ShareKeys extends Table {
|
||||
/// Random 16-byte key ID, hex-encoded. Identifies which key pair the sender
|
||||
/// used so the receiver can look it up even if multiple pairs exist.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Base64-encoded X25519 public key (32 bytes).
|
||||
TextColumn get publicKey => text()();
|
||||
|
||||
/// Base64-encoded X25519 private key (32 bytes).
|
||||
TextColumn get privateKey => text()();
|
||||
DateTimeColumn get expiresAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
@DataClassName('SearchHistoryRow')
|
||||
class SearchHistoryEntries extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get query => text()();
|
||||
DateTimeColumn get searchedAt => dateTime()();
|
||||
}
|
||||
|
||||
@DataClassName('LocalSieveScriptRow')
|
||||
class LocalSieveScripts extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get accountId =>
|
||||
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get content => text().withDefault(const Constant(''))();
|
||||
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
@DataClassName('UndoActionRow')
|
||||
class UndoActions extends Table {
|
||||
TextColumn get id => text()();
|
||||
@@ -292,21 +247,6 @@ class UndoActions extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Records which emails have already had local Sieve rules applied.
|
||||
/// Keyed by (accountId, messageId) so the same email is never processed twice,
|
||||
/// even across restarts or re-syncs.
|
||||
@DataClassName('LocalSieveAppliedRow')
|
||||
class LocalSieveApplied extends Table {
|
||||
TextColumn get accountId =>
|
||||
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||
// RFC 2822 Message-ID header value — stable across folder moves.
|
||||
TextColumn get messageId => text()();
|
||||
DateTimeColumn get appliedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {accountId, messageId};
|
||||
}
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@DriftDatabase(
|
||||
@@ -323,17 +263,13 @@ class LocalSieveApplied extends Table {
|
||||
SyncLogMailboxes,
|
||||
SyncHealth,
|
||||
UndoActions,
|
||||
SearchHistoryEntries,
|
||||
LocalSieveScripts,
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => dbSchemaVersion;
|
||||
int get schemaVersion => 26;
|
||||
|
||||
Future<void> _createEmailFts() async {
|
||||
await customStatement('''
|
||||
@@ -556,28 +492,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
SELECT rowid, subject, preview, from_json FROM emails
|
||||
''');
|
||||
}
|
||||
if (from < 27) {
|
||||
await m.createTable(searchHistoryEntries);
|
||||
}
|
||||
if (from < 28) {
|
||||
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
|
||||
}
|
||||
if (from < 29) {
|
||||
await m.createTable(localSieveScripts);
|
||||
}
|
||||
if (from >= 12 && from < 30) {
|
||||
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
|
||||
}
|
||||
if (from < 31) {
|
||||
await m.createTable(shareKeys);
|
||||
}
|
||||
if (from < 32) {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -587,96 +501,20 @@ String? _dbPath;
|
||||
|
||||
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
||||
/// path_provider plugin channel is registered before the first DB access.
|
||||
/// On some Android versions the Pigeon channel is not ready at the very
|
||||
/// start of main(); if it fails, _openConnection() retries lazily.
|
||||
Future<void> initDatabasePath() async {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
} on PlatformException {
|
||||
// Channel not yet established; LazyDatabase will resolve the path
|
||||
// on first access, after runApp() completes initialization.
|
||||
}
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
}
|
||||
|
||||
/// Resolve the application support path, retrying on PlatformException to
|
||||
/// survive a race where the path_provider Pigeon channel isn't ready yet.
|
||||
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];
|
||||
for (final ms in delays) {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
return _dbPath!;
|
||||
} on PlatformException {
|
||||
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 — '
|
||||
'cannot open database.',
|
||||
);
|
||||
}
|
||||
|
||||
// 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());
|
||||
final file = File(
|
||||
_dbPath ??
|
||||
p.join(
|
||||
(await getApplicationSupportDirectory()).path,
|
||||
'sharedinbox.db',
|
||||
),
|
||||
);
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
class LocalSieveRepository {
|
||||
LocalSieveRepository(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||
final rows = await (_db.select(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
return rows
|
||||
.map(
|
||||
(r) => SieveScript(
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
blobId: r.id.toString(),
|
||||
isActive: r.isActive,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
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),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw Exception('Local script not found: $blobId');
|
||||
return row.content;
|
||||
}
|
||||
|
||||
Future<SieveScript> saveScript(
|
||||
String accountId, {
|
||||
String? id,
|
||||
required String name,
|
||||
required String content,
|
||||
}) async {
|
||||
if (id != null) {
|
||||
final rowId = int.parse(id);
|
||||
await (_db.update(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.write(
|
||||
LocalSieveScriptsCompanion(
|
||||
name: Value(name),
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final updated = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return SieveScript(
|
||||
id: id,
|
||||
name: name,
|
||||
blobId: id,
|
||||
isActive: updated?.isActive ?? false,
|
||||
);
|
||||
}
|
||||
final rowId = await _db.into(_db.localSieveScripts).insert(
|
||||
LocalSieveScriptsCompanion.insert(
|
||||
accountId: accountId,
|
||||
name: name,
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final idStr = rowId.toString();
|
||||
return SieveScript(id: idStr, name: name, blobId: idStr, isActive: false);
|
||||
}
|
||||
|
||||
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),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
Future<void> activateScript(String accountId, String scriptId) async {
|
||||
await _db.transaction(() async {
|
||||
await (_db.update(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.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),
|
||||
))
|
||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,6 @@ import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_interpreter.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_parser.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||
import 'package:sharedinbox/core/utils/cid_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
@@ -239,9 +235,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||
final msg = fetch.messages.first;
|
||||
final textBody = msg.decodeTextPlainPart();
|
||||
final rawHtml = msg.decodeTextHtmlPart();
|
||||
final htmlBody =
|
||||
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
|
||||
final htmlBody = msg.decodeTextHtmlPart();
|
||||
final contentInfos = msg.findContentInfo();
|
||||
|
||||
final attachmentsJson = jsonEncode(
|
||||
@@ -265,8 +259,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.toList(),
|
||||
);
|
||||
|
||||
final mimeTreeJson = _buildMimeTreeJson(msg);
|
||||
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
@@ -274,7 +266,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: Value(htmlBody),
|
||||
attachmentsJson: Value(attachmentsJson),
|
||||
headersJson: Value(headersJson),
|
||||
mimeTreeJson: Value(mimeTreeJson),
|
||||
cachedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
@@ -284,7 +275,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: htmlBody,
|
||||
attachments: _parseAttachments(attachmentsJson),
|
||||
headers: _parseHeaders(headersJson),
|
||||
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
@@ -321,17 +311,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
'htmlBody',
|
||||
'bodyValues',
|
||||
'attachments',
|
||||
'bodyStructure',
|
||||
],
|
||||
'fetchHTMLBodyValues': true,
|
||||
'fetchTextBodyValues': true,
|
||||
'bodyProperties': [
|
||||
'partId',
|
||||
'type',
|
||||
'name',
|
||||
'size',
|
||||
'subParts',
|
||||
],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
@@ -351,12 +333,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
final rawBodyStructure =
|
||||
emailData['bodyStructure'] as Map<String, dynamic>?;
|
||||
final mimeTreeJson = rawBodyStructure != null
|
||||
? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure))
|
||||
: null;
|
||||
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
@@ -364,7 +340,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: Value(htmlBody),
|
||||
attachmentsJson: Value(attachmentsJson),
|
||||
headersJson: Value(headersJson),
|
||||
mimeTreeJson: Value(mimeTreeJson),
|
||||
cachedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
@@ -375,7 +350,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: htmlBody,
|
||||
attachments: _parseAttachments(attachmentsJson),
|
||||
headers: _parseHeaders(headersJson),
|
||||
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1474,8 +1448,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
@@ -1609,8 +1582,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
if (row.mailboxPath == destMailboxPath) {
|
||||
@@ -1678,8 +1650,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
// Move to Trash when possible so the user can recover the message.
|
||||
@@ -1873,22 +1844,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
@override
|
||||
@override
|
||||
Future<model.Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async {
|
||||
final row = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restoreEmails(List<model.Email> emails) async {
|
||||
for (final e in emails) {
|
||||
@@ -1920,218 +1875,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
||||
/// been processed yet. See [EmailRepository.applySieveRules] for details.
|
||||
@override
|
||||
Future<int> applySieveRules(String accountId) async {
|
||||
final scriptRow = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.isActive.equals(true),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (scriptRow == null) return 0;
|
||||
|
||||
List<SieveRule> rules;
|
||||
try {
|
||||
rules = SieveParser().parse(scriptRow.content);
|
||||
} catch (e) {
|
||||
log('Sieve parse error for account $accountId: $e');
|
||||
return 0;
|
||||
}
|
||||
if (rules.isEmpty) return 0;
|
||||
|
||||
final inboxMailbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||
|
||||
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||
|
||||
final inboxEmails = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(inboxPath) &
|
||||
t.messageId.isNotNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final interpreter = SieveInterpreter();
|
||||
var matched = 0;
|
||||
|
||||
for (final row in inboxEmails) {
|
||||
final msgId = row.messageId!;
|
||||
if (appliedIds.contains(msgId)) continue;
|
||||
|
||||
final emailCtx = _buildSieveContext(row);
|
||||
|
||||
SieveExecutionContext result;
|
||||
try {
|
||||
result = interpreter.execute(rules, emailCtx);
|
||||
} catch (e) {
|
||||
log('Sieve interpreter error for message $msgId: $e');
|
||||
await _markSieveApplied(accountId, msgId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _markSieveApplied(accountId, msgId);
|
||||
|
||||
if (result.isCancelled) {
|
||||
await _enqueueSieveDelete(account, row);
|
||||
matched++;
|
||||
} else if (result.targetFolders.isNotEmpty) {
|
||||
final dest = result.targetFolders.first;
|
||||
await _enqueueSieveMove(account, row, dest);
|
||||
matched++;
|
||||
} else if (result.flagsToAdd.isNotEmpty) {
|
||||
await _enqueueSieveFlagSeen(account, row);
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
SieveEmailContext _buildSieveContext(Email row) {
|
||||
String formatAddrs(String json) {
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.map((e) {
|
||||
final m = e as Map<String, dynamic>;
|
||||
final name = m['name'] as String? ?? '';
|
||||
final email = m['email'] as String? ?? '';
|
||||
return name.isEmpty ? email : '$name <$email>';
|
||||
}).join(', ');
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return SieveEmailContext(
|
||||
headers: {
|
||||
if (row.subject != null && row.subject!.isNotEmpty)
|
||||
'subject': [row.subject!],
|
||||
'from': [formatAddrs(row.fromJson)],
|
||||
'to': [formatAddrs(row.toAddresses)],
|
||||
'cc': [formatAddrs(row.ccJson)],
|
||||
if (row.messageId != null) 'message-id': [row.messageId!],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markSieveApplied(String accountId, String messageId) async {
|
||||
await _db.into(_db.localSieveApplied).insertOnConflictUpdate(
|
||||
LocalSieveAppliedCompanion.insert(
|
||||
accountId: accountId,
|
||||
messageId: messageId,
|
||||
appliedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _enqueueSieveMove(
|
||||
account_model.Account account,
|
||||
Email row,
|
||||
String folder,
|
||||
) async {
|
||||
String destPath;
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
final destMailbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(account.id) & t.name.equals(folder),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (destMailbox == null) {
|
||||
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||
return;
|
||||
}
|
||||
destPath = destMailbox.path;
|
||||
await _enqueueChange(
|
||||
account.id,
|
||||
row.id,
|
||||
'move',
|
||||
jsonEncode({'src': row.mailboxPath, 'dest': destPath}),
|
||||
);
|
||||
} else {
|
||||
destPath = folder;
|
||||
await _enqueueChange(
|
||||
account.id,
|
||||
row.id,
|
||||
'move',
|
||||
jsonEncode({
|
||||
'uid': row.uid,
|
||||
'mailboxPath': row.mailboxPath,
|
||||
'dest': destPath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||
EmailsCompanion(mailboxPath: Value(destPath)),
|
||||
);
|
||||
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||
await _updateThread(account.id, destPath, row.threadId ?? row.id);
|
||||
}
|
||||
|
||||
Future<void> _enqueueSieveDelete(
|
||||
account_model.Account account,
|
||||
Email row,
|
||||
) async {
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
await _enqueueChange(
|
||||
account.id,
|
||||
row.id,
|
||||
'delete',
|
||||
jsonEncode(<String, dynamic>{}),
|
||||
);
|
||||
} else {
|
||||
await _enqueueChange(
|
||||
account.id,
|
||||
row.id,
|
||||
'delete',
|
||||
jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}),
|
||||
);
|
||||
}
|
||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
||||
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||
}
|
||||
|
||||
Future<void> _enqueueSieveFlagSeen(
|
||||
account_model.Account account,
|
||||
Email row,
|
||||
) async {
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
await _enqueueChange(
|
||||
account.id,
|
||||
row.id,
|
||||
'flag_seen',
|
||||
jsonEncode({'seen': true}),
|
||||
);
|
||||
} else {
|
||||
await _enqueueChange(
|
||||
account.id,
|
||||
row.id,
|
||||
'flag_seen',
|
||||
jsonEncode({
|
||||
'uid': row.uid,
|
||||
'mailboxPath': row.mailboxPath,
|
||||
'seen': true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||
const EmailsCompanion(isSeen: Value(true)),
|
||||
);
|
||||
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||
}
|
||||
|
||||
/// Drains pending changes for [accountId] via the appropriate protocol.
|
||||
/// Called at the start of each sync cycle. Returns count of applied changes.
|
||||
@override
|
||||
@@ -2247,18 +1990,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.go();
|
||||
applied++;
|
||||
} catch (e) {
|
||||
if (_isImapNotFoundError(e)) {
|
||||
// Email already gone on the server — treat as success so the
|
||||
// pending change doesn't accumulate or block future changes.
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
applied++;
|
||||
log('IMAP change ${row.id} skipped: message already gone ($e)');
|
||||
} else {
|
||||
await _recordChangeError(row, e);
|
||||
}
|
||||
await _recordChangeError(row, e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -2267,19 +1999,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
return applied;
|
||||
}
|
||||
|
||||
bool _isImapNotFoundError(Object e) {
|
||||
final s = e.toString().toLowerCase();
|
||||
return s.contains('nonexistent') || s.contains('not found');
|
||||
}
|
||||
|
||||
Future<void> _applyPendingChangeImap(
|
||||
imap.ImapClient client,
|
||||
PendingChangeRow row,
|
||||
) async {
|
||||
final payload = jsonDecode(row.payload) as Map<String, dynamic>;
|
||||
final uid = payload['uid'] as int;
|
||||
// snooze/unsnooze payloads use 'src' for the source folder; all others use 'mailboxPath'.
|
||||
final mailboxPath = (payload['mailboxPath'] ?? payload['src']) as String;
|
||||
final mailboxPath = payload['mailboxPath'] as String;
|
||||
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
|
||||
@@ -2418,29 +2144,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final until = payload['until'] as String;
|
||||
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
||||
final keyword = 'snz:$timestamp';
|
||||
var destMailboxId = payload['dest'] as String;
|
||||
final destMailboxId = payload['dest'] as String;
|
||||
final srcMailboxId = payload['src'] as String;
|
||||
// When the Snoozed folder didn't exist at enqueue time, 'dest' holds
|
||||
// the literal name 'Snoozed' rather than a JMAP mailbox ID. Create it.
|
||||
if (destMailboxId == 'Snoozed') {
|
||||
final createResps = await jmap.call([
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'create': {
|
||||
'new-snoozed': {'name': 'Snoozed', 'role': 'snoozed'},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
final createResult = _responseArgs(createResps, 0, 'Mailbox/set');
|
||||
final created = createResult['created'] as Map<String, dynamic>?;
|
||||
final newId = (created?['new-snoozed']
|
||||
as Map<String, dynamic>?)?['id'] as String?;
|
||||
if (newId != null) destMailboxId = newId;
|
||||
}
|
||||
responses = await jmap.call([
|
||||
[
|
||||
'Email/set',
|
||||
@@ -2804,13 +2509,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
// Fetch the full message so enough_mail has MIME headers (including
|
||||
// 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[]',
|
||||
'BODY.PEEK[${attachment.fetchPartId}]',
|
||||
);
|
||||
final msg = fetch.messages.first;
|
||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||
@@ -2825,65 +2526,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async {
|
||||
final emailRow = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
final jmap = await JmapClient.connect(
|
||||
httpClient: _httpClient,
|
||||
jmapUrl: Uri.parse(account.jmapUrl!),
|
||||
username: _effectiveUsername(account),
|
||||
password: password,
|
||||
);
|
||||
final jmapEmailId = emailId.contains(':')
|
||||
? emailId.substring(emailId.indexOf(':') + 1)
|
||||
: emailId;
|
||||
final responses = await jmap.call([
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'ids': [jmapEmailId],
|
||||
'properties': ['id', 'blobId'],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
final result = _responseArgs(responses, 0, 'Email/get');
|
||||
final emailData =
|
||||
(result['list'] as List<dynamic>).first as Map<String, dynamic>;
|
||||
final blobId = emailData['blobId'] as String;
|
||||
final bytes = await jmap.downloadBlob(
|
||||
blobId,
|
||||
name: 'email.eml',
|
||||
type: 'message/rfc822',
|
||||
);
|
||||
return utf8.decode(bytes, allowMalformed: true);
|
||||
}
|
||||
|
||||
final client = await _imapConnect(
|
||||
account,
|
||||
_effectiveUsername(account),
|
||||
password,
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
return fetch.messages.first.renderMessage();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<model.Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
@@ -2947,52 +2589,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
return rows.map(_toModel).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<model.EmailAddress>> searchAddresses(
|
||||
String? accountId,
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
if (query.length < 2) return [];
|
||||
final pattern = '%${query.toLowerCase()}%';
|
||||
final rows = await (_db.select(_db.emails)
|
||||
..where((t) {
|
||||
Expression<bool> cond = const Constant(true);
|
||||
if (accountId != null) cond = t.accountId.equals(accountId);
|
||||
cond = cond &
|
||||
(t.fromJson.like(pattern) |
|
||||
t.toAddresses.like(pattern) |
|
||||
t.ccJson.like(pattern));
|
||||
return cond;
|
||||
})
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||
..limit(100))
|
||||
.get();
|
||||
|
||||
final seen = <String>{};
|
||||
final results = <model.EmailAddress>[];
|
||||
final lowerQuery = query.toLowerCase();
|
||||
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>;
|
||||
final addr = model.EmailAddress(
|
||||
name: map['name'] as String?,
|
||||
email: map['email'] as String,
|
||||
);
|
||||
if ((addr.email.toLowerCase().contains(lowerQuery) ||
|
||||
(addr.name?.toLowerCase().contains(lowerQuery) ?? false)) &&
|
||||
seen.add(addr.email.toLowerCase())) {
|
||||
results.add(addr);
|
||||
if (results.length >= limit) return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<model.Email>> searchEmails(
|
||||
String accountId,
|
||||
@@ -3155,27 +2751,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: row.htmlBody,
|
||||
attachments: _parseAttachments(row.attachmentsJson),
|
||||
headers: _parseHeaders(row.headersJson),
|
||||
mimeTree: _parseMimeTree(row.mimeTreeJson),
|
||||
);
|
||||
|
||||
model.MimePart? _parseMimeTree(String? jsonStr) {
|
||||
if (jsonStr == null || jsonStr.isEmpty) return null;
|
||||
try {
|
||||
return _mimePartFromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
model.MimePart _mimePartFromJson(Map<String, dynamic> m) => model.MimePart(
|
||||
contentType: m['contentType'] as String? ?? 'application/octet-stream',
|
||||
filename: m['filename'] as String?,
|
||||
size: m['size'] as int?,
|
||||
encoding: m['encoding'] as String?,
|
||||
children: ((m['children'] as List<dynamic>?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(_mimePartFromJson)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
List<model.EmailHeader> _parseHeaders(String? jsonStr) {
|
||||
@@ -3267,36 +2842,3 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively converts an [imap.MimePart] into a JSON-serialisable map.
|
||||
Map<String, dynamic> _mimePartToJson(imap.MimePart part) {
|
||||
final ct = part.getHeaderContentType();
|
||||
final disposition = part.getHeaderContentDisposition();
|
||||
final rawEncoding =
|
||||
part.getHeader('content-transfer-encoding')?.firstOrNull?.value;
|
||||
final encoding = rawEncoding?.split(';').first.trim().toLowerCase();
|
||||
return {
|
||||
'contentType': ct?.mediaType.text ?? 'application/octet-stream',
|
||||
'filename': disposition?.filename ?? ct?.parameters['name'],
|
||||
'size': disposition?.size,
|
||||
'encoding': encoding,
|
||||
'children': (part.parts ?? []).map(_mimePartToJson).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds a JSON string representing the MIME tree of [msg].
|
||||
String _buildMimeTreeJson(imap.MimeMessage msg) =>
|
||||
jsonEncode(_mimePartToJson(msg));
|
||||
|
||||
/// Converts a JMAP `bodyStructure` object into the same JSON format used by
|
||||
/// [_mimePartToJson], so [_parseMimeTree] can deserialise it uniformly.
|
||||
Map<String, dynamic> _jmapBodyStructureToJson(Map<String, dynamic> m) => {
|
||||
'contentType': m['type'] as String? ?? 'application/octet-stream',
|
||||
'filename': m['name'],
|
||||
'size': m['size'],
|
||||
'encoding': null,
|
||||
'children': ((m['subParts'] as List<dynamic>?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(_jmapBodyStructureToJson)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
SearchHistoryRepositoryImpl(this._db);
|
||||
final AppDatabase _db;
|
||||
|
||||
static const _maxEntries = 10;
|
||||
|
||||
@override
|
||||
Future<List<String>> getRecentSearches() async {
|
||||
final rows = await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.get();
|
||||
return rows.map((r) => r.query).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSearch(String query) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
await _db.transaction(() async {
|
||||
// Remove existing entry for same query (deduplication).
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.query.equals(trimmed)))
|
||||
.go();
|
||||
|
||||
await _db.into(_db.searchHistoryEntries).insert(
|
||||
SearchHistoryEntriesCompanion.insert(
|
||||
query: trimmed,
|
||||
searchedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// Prune to the most recent _maxEntries.
|
||||
final keepIds = await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.map((r) => r.id)
|
||||
.get();
|
||||
|
||||
if (keepIds.isNotEmpty) {
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.id.isNotIn(keepIds)))
|
||||
.go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearHistory() async {
|
||||
await _db.delete(_db.searchHistoryEntries).go();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
/// Drift-backed implementation of [ShareKeyRepository].
|
||||
///
|
||||
/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a
|
||||
/// new key pair is created or looked up.
|
||||
class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
ShareKeyRepositoryImpl(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial> createKeyPair() async {
|
||||
await _pruneExpired();
|
||||
|
||||
final material = await ShareEncryptionService.generateKeyPair();
|
||||
final keyIdHex = _hex(material.keyId);
|
||||
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
|
||||
await _db.into(_db.shareKeys).insert(
|
||||
ShareKeysCompanion.insert(
|
||||
id: keyIdHex,
|
||||
publicKey: base64.encode(material.publicKeyBytes),
|
||||
privateKey: base64.encode(material.privateKeyBytes),
|
||||
expiresAt: expiresAt,
|
||||
),
|
||||
);
|
||||
|
||||
return material;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId) async {
|
||||
await _pruneExpired();
|
||||
|
||||
final keyIdHex = _hex(keyId);
|
||||
final row = await (_db.select(_db.shareKeys)
|
||||
..where((t) => t.id.equals(keyIdHex)))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (row == null) return null;
|
||||
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
|
||||
|
||||
return ShareKeyMaterial(
|
||||
keyId: keyId,
|
||||
publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)),
|
||||
privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pruneExpired() async {
|
||||
await (_db.delete(_db.shareKeys)
|
||||
..where(
|
||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
static String _hex(Uint8List bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
@@ -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),
|
||||
@@ -53,7 +49,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
fetched: Value(s.fetched),
|
||||
skipped: Value(s.skipped),
|
||||
bytesTransferred: Value(s.bytesTransferred),
|
||||
durationMs: Value(s.duration?.inMilliseconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -79,8 +74,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,
|
||||
@@ -97,9 +90,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
fetched: m.fetched,
|
||||
skipped: m.skipped,
|
||||
bytesTransferred: m.bytesTransferred,
|
||||
duration: m.durationMs != null
|
||||
? Duration(milliseconds: m.durationMs!)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
|
||||
+12
-29
@@ -9,9 +9,6 @@ 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/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
@@ -22,15 +19,12 @@ 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;
|
||||
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';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||
@@ -64,10 +58,6 @@ final accountRepositoryProvider = Provider<AccountRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final shareKeyRepositoryProvider = Provider<ShareKeyRepository>((ref) {
|
||||
return ShareKeyRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
||||
return MailboxRepositoryImpl(
|
||||
ref.watch(dbProvider),
|
||||
@@ -97,12 +87,7 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final searchHistoryRepositoryProvider =
|
||||
Provider<SearchHistoryRepository>((ref) {
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||
final syncLogRepositoryProvider = Provider((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
@@ -163,10 +148,6 @@ final sieveRepositoryProvider = Provider<SieveRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final localSieveRepositoryProvider = Provider<LocalSieveRepository>((ref) {
|
||||
return LocalSieveRepository(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
||||
return ConnectionTestServiceImpl(
|
||||
ref.watch(httpClientProvider),
|
||||
@@ -182,7 +163,11 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||
});
|
||||
|
||||
final undoServiceProvider =
|
||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
|
||||
final service = UndoService(ref);
|
||||
unawaited(service.init());
|
||||
return service;
|
||||
});
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
@@ -191,18 +176,16 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
||||
EmailDetailNotifier.new,
|
||||
);
|
||||
|
||||
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
EmailDetailNotifier(this._emailId);
|
||||
final String _emailId;
|
||||
|
||||
class EmailDetailNotifier
|
||||
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
||||
@override
|
||||
Future<(Email?, EmailBody)> build() async {
|
||||
Future<(Email?, EmailBody)> build(String emailId) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
repo.getEmail(_emailId),
|
||||
repo.getEmailBody(_emailId),
|
||||
repo.getEmail(emailId),
|
||||
repo.getEmailBody(emailId),
|
||||
]);
|
||||
unawaited(repo.setFlag(_emailId, seen: true));
|
||||
unawaited(repo.setFlag(emailId, seen: true));
|
||||
return (results[0] as Email?, results[1] as EmailBody);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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';
|
||||
@@ -71,7 +70,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: 'sharedinbox.de',
|
||||
title: 'SharedInbox',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
|
||||
@@ -2,10 +2,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
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/account_receive_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
@@ -36,14 +33,6 @@ final router = GoRouter(
|
||||
path: 'add',
|
||||
builder: (ctx, state) => const AddAccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'receive',
|
||||
builder: (ctx, state) => const AccountReceiveScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'send',
|
||||
builder: (ctx, state) => const AccountSendScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
@@ -52,10 +41,6 @@ final router = GoRouter(
|
||||
path: 'changelog',
|
||||
builder: (ctx, state) => const ChangeLogScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'about',
|
||||
builder: (ctx, state) => const AboutScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
@@ -80,21 +65,6 @@ final router = GoRouter(
|
||||
script: state.extra as SieveScript?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/sieve/local',
|
||||
builder: (ctx, state) => SieveScriptsScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
isLocal: true,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/sieve/local/edit',
|
||||
builder: (ctx, state) => SieveScriptEditScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
script: state.extra as SieveScript?,
|
||||
isLocal: true,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/search',
|
||||
builder: (ctx, state) =>
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package: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 {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AboutScreen> createState() => _AboutScreenState();
|
||||
}
|
||||
|
||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||
late final Future<String?> _deviceModelFuture;
|
||||
late final Stream<List<Account>> _accountsStream;
|
||||
String? _deviceModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||
_deviceModelFuture = getDeviceModel();
|
||||
unawaited(
|
||||
_deviceModelFuture.then((model) {
|
||||
if (mounted) setState(() => _deviceModel = model);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _copyToClipboard(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) async {
|
||||
PackageInfo? pkg;
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
int jmapCount,
|
||||
) async {
|
||||
PackageInfo? pkg;
|
||||
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,
|
||||
),
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
);
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<List<Account>>(
|
||||
stream: _accountsStream,
|
||||
builder: (context, accountSnapshot) {
|
||||
final accounts = accountSnapshot.data ?? [];
|
||||
final imapCount =
|
||||
accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount =
|
||||
accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('About')),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FutureBuilder<PackageInfo>(
|
||||
future: _packageInfoFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return Markdown(
|
||||
data: buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: snapshot.data,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: _deviceModel,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
_launchUrl(context, Uri.parse(href)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to clipboard'),
|
||||
onPressed: () => unawaited(
|
||||
_copyToClipboard(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Create issue'),
|
||||
onPressed: () => unawaited(
|
||||
_createIssue(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/services/update_service.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AccountListScreen extends ConsumerWidget {
|
||||
const AccountListScreen({super.key});
|
||||
@@ -15,7 +14,7 @@ class AccountListScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('sharedinbox.de'),
|
||||
title: const Text('SharedInbox'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
@@ -30,18 +29,10 @@ class AccountListScreen extends ConsumerWidget {
|
||||
const DrawerHeader(
|
||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||
child: Text(
|
||||
'sharedinbox.de',
|
||||
'SharedInbox',
|
||||
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.qr_code_scanner),
|
||||
title: const Text('Receive accounts'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/receive'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: const Text('Undo Log'),
|
||||
@@ -58,39 +49,37 @@ class AccountListScreen extends ConsumerWidget {
|
||||
unawaited(context.push('/accounts/changelog'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('About'),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close drawer
|
||||
unawaited(context.push('/accounts/about'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const _UpdateBanner(),
|
||||
Expanded(
|
||||
child: StreamBuilder(
|
||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final accounts = snap.data!;
|
||||
if (accounts.isEmpty) {
|
||||
return const _OnboardingView();
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
body: StreamBuilder(
|
||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final accounts = snap.data!;
|
||||
if (accounts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('No accounts yet.'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/accounts/add'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/accounts/add'),
|
||||
@@ -170,27 +159,15 @@ class _AccountTile extends ConsumerWidget {
|
||||
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'),
|
||||
value: _AccountAction.emailFilters,
|
||||
child: Text('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,
|
||||
@@ -217,53 +194,16 @@ class _AccountTile extends ConsumerWidget {
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Starting sync verification...'),
|
||||
),
|
||||
const SnackBar(content: Text('Starting sync verification...')),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case _AccountAction.forceSync:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Force full sync?'),
|
||||
content: const Text(
|
||||
'This clears all locally-cached emails and mailboxes for this '
|
||||
'account and immediately re-downloads everything from the server. '
|
||||
'Previously viewed email content will not need to be re-downloaded.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Force sync'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && context.mounted) {
|
||||
await ProviderScope.containerOf(
|
||||
context,
|
||||
).read(syncManagerProvider).forceResync(account.id);
|
||||
}
|
||||
break;
|
||||
case _AccountAction.edit:
|
||||
await context.push('/accounts/${account.id}/edit');
|
||||
break;
|
||||
case _AccountAction.emailFiltersRemote:
|
||||
case _AccountAction.emailFilters:
|
||||
await context.push('/accounts/${account.id}/sieve');
|
||||
break;
|
||||
case _AccountAction.emailFiltersLocal:
|
||||
await context.push('/accounts/${account.id}/sieve/local');
|
||||
break;
|
||||
case _AccountAction.send:
|
||||
await context.push('/accounts/send');
|
||||
break;
|
||||
case _AccountAction.delete:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -293,122 +233,7 @@ class _AccountTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingView extends StatelessWidget {
|
||||
const _OnboardingView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mail_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Welcome to sharedinbox.de',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Get started in three steps:',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _Step(
|
||||
number: '1',
|
||||
title: 'Add an account',
|
||||
description: 'Connect your IMAP or JMAP email account.',
|
||||
),
|
||||
const _Step(
|
||||
number: '2',
|
||||
title: 'Wait for sync',
|
||||
description:
|
||||
'sharedinbox.de downloads your messages in the background.',
|
||||
),
|
||||
const _Step(
|
||||
number: '3',
|
||||
title: 'Open your inbox',
|
||||
description:
|
||||
'Tap the account to browse mailboxes and read emails.',
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/accounts/add'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Step extends StatelessWidget {
|
||||
const _Step({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
final String number;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
number,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleSmall),
|
||||
Text(description, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccountAction {
|
||||
syncLog,
|
||||
verifySync,
|
||||
forceSync,
|
||||
edit,
|
||||
emailFiltersRemote,
|
||||
emailFiltersLocal,
|
||||
send,
|
||||
delete,
|
||||
}
|
||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
||||
|
||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||
///
|
||||
@@ -420,31 +245,3 @@ bool _sieveSupported(Account account) {
|
||||
if (account.type == AccountType.jmap) return true;
|
||||
return account.manageSieveAvailable != false;
|
||||
}
|
||||
|
||||
/// Shown on Linux desktop when a newer build is available on the server.
|
||||
class _UpdateBanner extends ConsumerWidget {
|
||||
const _UpdateBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final update = ref.watch(updateInfoProvider);
|
||||
return update.when(
|
||||
data: (info) {
|
||||
if (info == null) return const SizedBox.shrink();
|
||||
return MaterialBanner(
|
||||
content: Text('Update available: ${info.latestVersion}'),
|
||||
leading: const Icon(Icons.system_update),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
unawaited(launchUrl(Uri.parse(info.downloadUrl))),
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
/// Receiving side of the secure account-sharing flow.
|
||||
///
|
||||
/// Step 1 – generates an X25519 key pair with a 20-minute lifetime and shows
|
||||
/// the public key as a QR code to be scanned by the sender.
|
||||
///
|
||||
/// Step 2 – scans the encrypted-accounts QR code shown by the sender, decrypts
|
||||
/// it using the private key, and imports the accounts.
|
||||
class AccountReceiveScreen extends ConsumerStatefulWidget {
|
||||
const AccountReceiveScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AccountReceiveScreen> createState() =>
|
||||
_AccountReceiveScreenState();
|
||||
}
|
||||
|
||||
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() {
|
||||
super.initState();
|
||||
unawaited(_generateKey());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final ctrl = _scannerController;
|
||||
if (ctrl != null) unawaited(ctrl.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _generateKey() async {
|
||||
try {
|
||||
final repo = ref.read(shareKeyRepositoryProvider);
|
||||
final material = await repo.createKeyPair();
|
||||
final qr = ShareEncryptionService.encodePublicKeyQr(
|
||||
material.keyId,
|
||||
material.publicKeyBytes,
|
||||
);
|
||||
setState(() {
|
||||
_keyMaterial = material;
|
||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
_pubKeyQr = qr;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_step = _Step.error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startScanning() {
|
||||
setState(() {
|
||||
_step = _Step.scanning;
|
||||
_scannerActive = true;
|
||||
});
|
||||
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 {
|
||||
if (!_scannerActive) return;
|
||||
_scannerActive = false;
|
||||
await _scannerController?.stop();
|
||||
|
||||
setState(() => _step = _Step.importing);
|
||||
|
||||
try {
|
||||
final material = _keyMaterial!;
|
||||
final accounts = await ShareEncryptionService.decryptAccounts(
|
||||
qrString: rawValue,
|
||||
privateKeyBytes: material.privateKeyBytes,
|
||||
publicKeyBytes: material.publicKeyBytes,
|
||||
keyId: material.keyId,
|
||||
);
|
||||
|
||||
final repo = ref.read(accountRepositoryProvider);
|
||||
for (final ap in accounts) {
|
||||
final account = Account.fromJson(ap.accountJson);
|
||||
final newAccount = Account(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
displayName: account.displayName,
|
||||
email: account.email,
|
||||
username: account.username,
|
||||
type: account.type,
|
||||
imapHost: account.imapHost,
|
||||
imapPort: account.imapPort,
|
||||
imapSsl: account.imapSsl,
|
||||
smtpHost: account.smtpHost,
|
||||
smtpPort: account.smtpPort,
|
||||
smtpSsl: account.smtpSsl,
|
||||
manageSieveHost: account.manageSieveHost,
|
||||
manageSievePort: account.manageSievePort,
|
||||
manageSieveSsl: account.manageSieveSsl,
|
||||
jmapUrl: account.jmapUrl,
|
||||
);
|
||||
await repo.addAccount(newAccount, ap.password);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _step = _Step.done);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported ${accounts.length} account${accounts.length == 1 ? '' : 's'} successfully.',
|
||||
),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = _friendlyError(e);
|
||||
_scannerActive = false;
|
||||
// Let user retry from the pubkey step.
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_friendlyError(e)),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _friendlyError(Object e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('expired') || s.contains('older than')) {
|
||||
return 'The QR code has expired. Ask the sender to generate a new one.';
|
||||
}
|
||||
if (s.contains('Key ID mismatch') || s.contains('Unknown')) {
|
||||
return 'QR code does not match this session. Regenerate the public key and try again.';
|
||||
}
|
||||
if (s.contains('authentication') ||
|
||||
s.contains('mac') ||
|
||||
s.contains('SecretBox')) {
|
||||
return 'Authentication failed — the QR code may have been tampered with.';
|
||||
}
|
||||
return 'Import failed: $s';
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Receive accounts')),
|
||||
body: switch (_step) {
|
||||
_Step.generatingKey => const Center(child: CircularProgressIndicator()),
|
||||
_Step.showingPubKey => _buildPubKeyView(context),
|
||||
_Step.scanning => _buildScannerView(context),
|
||||
_Step.importing => const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Importing accounts…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
_Step.done => const Center(
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
size: 64,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
_Step.error => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error: $_errorMessage'),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPubKeyView(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Step 1 of 2 — Show this QR code to the sender',
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The sender scans this code, selects the account(s) to transfer, '
|
||||
'and shows an encrypted QR code. Then come back here for step 2.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: QrImageView(
|
||||
key: const Key('pubKeyQrCode'),
|
||||
data: _pubKeyQr!,
|
||||
size: 260,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy public key'),
|
||||
onPressed: () {
|
||||
unawaited(Clipboard.setData(ClipboardData(text: _pubKeyQr!)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Public key copied to clipboard')),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||
const SizedBox(height: 32),
|
||||
if (_errorMessage != null) ...[
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FilledButton.icon(
|
||||
key: const Key('scanEncryptedButton'),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('Step 2 — Scan encrypted QR code'),
|
||||
onPressed: _startScanning,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scannerController!,
|
||||
onDetect: (capture) {
|
||||
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (raw != null) unawaited(_onScanned(raw));
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: const Text(
|
||||
'Point the camera at the encrypted QR code from the sender\'s device',
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 32,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
final ctrl = _scannerController;
|
||||
if (ctrl != null) unawaited(ctrl.dispose());
|
||||
_scannerController = null;
|
||||
setState(() {
|
||||
_scannerActive = false;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFallbackView(BuildContext context) {
|
||||
final ctrl = TextEditingController();
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Paste the encrypted code from the sender\'s device',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const Key('encryptedCodeField'),
|
||||
controller: ctrl,
|
||||
maxLines: 6,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Encrypted code',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'sharedinbox.de:encrypted-accounts:v1:…',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final text = ctrl.text.trim();
|
||||
if (text.isNotEmpty) unawaited(_onScanned(text));
|
||||
},
|
||||
child: const Text('Import'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() {
|
||||
_scannerActive = false;
|
||||
_step = _Step.showingPubKey;
|
||||
}),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _cameraScanSupported() =>
|
||||
Platform.isAndroid ||
|
||||
Platform.isIOS ||
|
||||
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')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'This key expires in ${_formatRemaining()}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
/// Sending side of the secure account-sharing flow.
|
||||
///
|
||||
/// Step 1 – scans (or pastes) the receiver's public-key QR code.
|
||||
///
|
||||
/// Step 2 – if more than one account exists, the user selects which accounts
|
||||
/// to transfer (auto-selected when only one account is present).
|
||||
///
|
||||
/// Step 3 – shows the encrypted-accounts QR code for the receiver to scan.
|
||||
class AccountSendScreen extends ConsumerStatefulWidget {
|
||||
const AccountSendScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AccountSendScreen> createState() => _AccountSendScreenState();
|
||||
}
|
||||
|
||||
enum _Step { scanning, selectAccounts, showEncrypted, error }
|
||||
|
||||
class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
_Step _step = _Step.scanning;
|
||||
|
||||
// Set after scanning the pubkey QR.
|
||||
Uint8List? _recipientKeyId;
|
||||
Uint8List? _recipientPublicKey;
|
||||
|
||||
// All available accounts + the selection (for step 2).
|
||||
List<Account> _accounts = [];
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
// Set after encryption (step 3).
|
||||
String? _encryptedQr;
|
||||
String? _errorMessage;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final ctrl = _scannerController;
|
||||
if (ctrl != null) unawaited(ctrl.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── Step 1: scan pubkey QR ──────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onPubKeyScanned(String rawValue) async {
|
||||
if (!_scannerActive) return;
|
||||
_scannerActive = false;
|
||||
await _scannerController?.stop();
|
||||
|
||||
final parsed = ShareEncryptionService.parsePublicKeyQr(rawValue);
|
||||
if (parsed == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Not a valid sharedinbox.de public-key QR code. '
|
||||
'Ask the receiver to show step 1 of "Receive accounts".',
|
||||
),
|
||||
),
|
||||
);
|
||||
// Allow retry.
|
||||
setState(() => _scannerActive = true);
|
||||
await _scannerController?.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all available accounts.
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (accounts.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = 'No accounts to send.';
|
||||
_step = _Step.error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_recipientKeyId = parsed.keyId;
|
||||
_recipientPublicKey = parsed.publicKeyBytes;
|
||||
_accounts = accounts;
|
||||
});
|
||||
|
||||
if (accounts.length == 1) {
|
||||
// Auto-select the only account; skip the selection step.
|
||||
_selectedIds.add(accounts.first.id);
|
||||
await _encryptAndShow();
|
||||
} else {
|
||||
setState(() {
|
||||
_selectedIds.addAll(accounts.map((a) => a.id));
|
||||
_step = _Step.selectAccounts;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: account selection ───────────────────────────────────────────────
|
||||
|
||||
Future<void> _encryptAndShow() async {
|
||||
final repo = ref.read(accountRepositoryProvider);
|
||||
final selected = _accounts.where((a) => _selectedIds.contains(a.id));
|
||||
|
||||
final payloads = <AccountPayload>[];
|
||||
for (final account in selected) {
|
||||
final password = await repo.getPassword(account.id);
|
||||
payloads.add(
|
||||
AccountPayload(
|
||||
accountJson: account.toJson(),
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final qr = await ShareEncryptionService.encryptAccounts(
|
||||
recipientKeyId: _recipientKeyId!,
|
||||
recipientPublicKeyBytes: _recipientPublicKey!,
|
||||
accounts: payloads,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_encryptedQr = qr;
|
||||
_step = _Step.showEncrypted;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_step = _Step.error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Send accounts')),
|
||||
body: switch (_step) {
|
||||
_Step.scanning => _buildScanStep(context),
|
||||
_Step.selectAccounts => _buildSelectStep(context),
|
||||
_Step.showEncrypted => _buildEncryptedQrStep(context),
|
||||
_Step.error => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error: $_errorMessage'),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScanStep(BuildContext context) {
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scannerController!,
|
||||
onDetect: (capture) {
|
||||
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (raw != null) unawaited(_onPubKeyScanned(raw));
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: const Text(
|
||||
'Point the camera at the public-key QR code shown by the receiver',
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFallbackView(BuildContext context) {
|
||||
final ctrl = TextEditingController();
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Paste the public key shown by the receiver\'s "Receive accounts" screen.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const Key('pubKeyInputField'),
|
||||
controller: ctrl,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Public key',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'sharedinbox.de:pubkey:v1:…',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final text = ctrl.text.trim();
|
||||
if (text.isNotEmpty) unawaited(_onPubKeyScanned(text));
|
||||
},
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectStep(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Select accounts to send',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: _accounts.map((account) {
|
||||
final selected = _selectedIds.contains(account.id);
|
||||
return CheckboxListTile(
|
||||
value: selected,
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text(account.email),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
if (v == true) {
|
||||
_selectedIds.add(account.id);
|
||||
} else {
|
||||
_selectedIds.remove(account.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton(
|
||||
key: const Key('sendSelectedButton'),
|
||||
onPressed: _selectedIds.isEmpty
|
||||
? null
|
||||
: () => unawaited(_encryptAndShow()),
|
||||
child: const Text('Encrypt & show QR'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEncryptedQrStep(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Step 3 — Show this QR code to the receiver',
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The receiver taps "Step 2 — Scan encrypted QR code" and scans this.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: QrImageView(
|
||||
key: const Key('encryptedAccountsQrCode'),
|
||||
data: _encryptedQr!,
|
||||
size: 280,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
key: const Key('copyEncryptedButton'),
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy encrypted code'),
|
||||
onPressed: () {
|
||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Encrypted code copied to clipboard',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This code contains encrypted account data. It is safe to display '
|
||||
'briefly — only the receiver\'s device can decrypt it.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _cameraScanSupported() =>
|
||||
Platform.isAndroid ||
|
||||
Platform.isIOS ||
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows;
|
||||
@@ -295,13 +295,6 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
onPressed: _detectAccount,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
key: const Key('importAccountButton'),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('Receive account'),
|
||||
onPressed: () => context.push('/accounts/receive'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChangeLogScreen extends StatelessWidget {
|
||||
@@ -12,8 +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());
|
||||
|
||||
@@ -39,8 +39,6 @@ class ComposeScreen extends ConsumerStatefulWidget {
|
||||
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
final _to = TextEditingController();
|
||||
final _cc = TextEditingController();
|
||||
final _toFocus = FocusNode();
|
||||
final _ccFocus = FocusNode();
|
||||
final _subject = TextEditingController();
|
||||
final _body = TextEditingController();
|
||||
String? _accountId;
|
||||
@@ -141,8 +139,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
c.removeListener(_onTextChanged);
|
||||
c.dispose();
|
||||
}
|
||||
_toFocus.dispose();
|
||||
_ccFocus.dispose();
|
||||
// Flush any pending save synchronously — we can't await in dispose, but
|
||||
// scheduling a microtask still runs before the isolate exits.
|
||||
if (_draftDirty) {
|
||||
@@ -162,7 +158,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;
|
||||
@@ -196,12 +192,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to open file: $e'),
|
||||
),
|
||||
);
|
||||
).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _opening = false);
|
||||
}
|
||||
@@ -215,12 +206,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (_accountId == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Select an account first'),
|
||||
),
|
||||
);
|
||||
).showSnackBar(const SnackBar(content: Text('Select an account first')));
|
||||
return;
|
||||
}
|
||||
setState(() => _sending = true);
|
||||
@@ -257,12 +243,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Send failed: $e'),
|
||||
),
|
||||
);
|
||||
).showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
@@ -334,8 +315,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_addressField(_to, _toFocus, 'To'),
|
||||
_addressField(_cc, _ccFocus, 'Cc'),
|
||||
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
|
||||
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
|
||||
_field(_subject, 'Subject'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
@@ -388,96 +369,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _addressField(
|
||||
TextEditingController ctrl,
|
||||
FocusNode focusNode,
|
||||
String label,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: RawAutocomplete<EmailAddress>(
|
||||
textEditingController: ctrl,
|
||||
focusNode: focusNode,
|
||||
displayStringForOption: (option) {
|
||||
final text = ctrl.text;
|
||||
final lastComma = text.lastIndexOf(',');
|
||||
final prefix =
|
||||
lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : '';
|
||||
return '$prefix${option.email}, ';
|
||||
},
|
||||
optionsBuilder: (value) async {
|
||||
final text = value.text;
|
||||
final lastComma = text.lastIndexOf(',');
|
||||
final token = lastComma >= 0
|
||||
? text.substring(lastComma + 1).trim()
|
||||
: text.trim();
|
||||
if (token.length < 2) return const [];
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchAddresses(null, token);
|
||||
// Guard: if focus left the field while the query was running,
|
||||
// return empty so RawAutocomplete doesn't call show() after hide()
|
||||
// has already been called — that races into an assertion in overlay.dart.
|
||||
if (!focusNode.hasFocus) return const [];
|
||||
return results;
|
||||
},
|
||||
fieldViewBuilder: (ctx, fieldCtrl, fieldFocusNode, onFieldSubmitted) {
|
||||
return TextFormField(
|
||||
controller: fieldCtrl,
|
||||
focusNode: fieldFocusNode,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (ctx, onSelected, options) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final option = options.elementAt(i);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: option.name != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(option.name!),
|
||||
Text(
|
||||
option.email,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(option.email),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(
|
||||
TextEditingController ctrl,
|
||||
String label, {
|
||||
|
||||
@@ -1,9 +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';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class CrashScreen extends StatelessWidget {
|
||||
@@ -11,48 +7,10 @@ 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();
|
||||
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'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -62,86 +20,39 @@ class CrashScreen extends StatelessWidget {
|
||||
title: const Text('Something went wrong'),
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
),
|
||||
body: Builder(
|
||||
builder: (ctx) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'SharedInbox encountered an unexpected error and needs to be restarted.',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Error Details:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
exception.toString(),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (stackTrace != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
|
||||
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:',
|
||||
'Stack Trace:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -152,94 +63,64 @@ class CrashScreen extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
exception.toString(),
|
||||
stackTrace.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (stackTrace != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Stack Trace:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
stackTrace.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final data = await _buildReport();
|
||||
await Clipboard.setData(ClipboardData(text: data));
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final data = 'Error: $exception\n\nStack Trace:\n$stackTrace';
|
||||
await Clipboard.setData(ClipboardData(text: data));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied to clipboard')),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to Clipboard'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final title = Uri.encodeComponent(
|
||||
'Crash: ${exception.toString().split('\n').first}',
|
||||
);
|
||||
final body = Uri.encodeComponent(
|
||||
'Error: $exception\n\nStack Trace:\n$stackTrace',
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
|
||||
);
|
||||
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('Copied to clipboard'),
|
||||
content: Text('Could not open browser.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to Clipboard'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
// URL carries only the title to avoid exceeding browser
|
||||
// URL-length limits — long stack traces caused "create
|
||||
// issue failed" (#146). Use "Copy to Clipboard" first to
|
||||
// get the full report, then paste it in the issue body.
|
||||
final title = Uri.encodeComponent(
|
||||
'Crash: ${exception.toString().split('\n').first}',
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title',
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Could not open browser.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Error: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Report Issue on Codeberg'),
|
||||
),
|
||||
],
|
||||
),
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Report Issue on Codeberg'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -38,12 +38,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
var _sieveSsl = true;
|
||||
var _verbose = false;
|
||||
final _jmapUrlCtrl = TextEditingController();
|
||||
bool _hasStoredPassword = false;
|
||||
|
||||
// -- "Try connection" state ------------------------------------------------
|
||||
bool _tryTesting = false;
|
||||
String? _tryOk;
|
||||
String? _tryErr;
|
||||
bool _resyncing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,7 +51,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.addListener(_rebuild);
|
||||
_sieveHostCtrl.addListener(_rebuild);
|
||||
_imapHostCtrl.addListener(_rebuild);
|
||||
_passwordCtrl.addListener(_rebuild);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@@ -65,11 +64,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 +85,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.removeListener(_rebuild);
|
||||
_sieveHostCtrl.removeListener(_rebuild);
|
||||
_imapHostCtrl.removeListener(_rebuild);
|
||||
_passwordCtrl.removeListener(_rebuild);
|
||||
for (final c in [
|
||||
_displayNameCtrl,
|
||||
_usernameCtrl,
|
||||
@@ -178,6 +171,43 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forceResync() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Force full sync?'),
|
||||
content: const Text(
|
||||
'This clears all locally-cached emails and mailboxes for this '
|
||||
'account and immediately re-downloads everything from the server. '
|
||||
'Previously viewed email content will not need to be re-downloaded.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Force sync'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _resyncing = true);
|
||||
try {
|
||||
await ref.read(syncManagerProvider).forceResync(widget.accountId);
|
||||
if (mounted) context.pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_resyncing = false;
|
||||
_errorMessage = 'Force sync failed: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
|
||||
@@ -238,7 +268,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Edit account')),
|
||||
body: _loading || _saving
|
||||
body: _loading || _saving || _resyncing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildForm(),
|
||||
);
|
||||
@@ -275,12 +305,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,16 +383,18 @@ 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')),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.sync_problem),
|
||||
label: const Text('Force full sync'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: _forceResync,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
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/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.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';
|
||||
|
||||
@@ -43,22 +38,18 @@ 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 isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final header = detail.valueOrNull?.$1;
|
||||
final body = detail.valueOrNull?.$2;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !isMobile,
|
||||
title: Text(
|
||||
header?.subject ?? '(loading…)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -69,20 +60,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
tooltip: 'Reply',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(
|
||||
_replyWithRecipientDialog(context, header, body),
|
||||
);
|
||||
},
|
||||
: () => _reply(context, header, body, replyAll: false),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply_all),
|
||||
tooltip: 'Reply all',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () => _reply(context, header, body, replyAll: true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
onPressed:
|
||||
header == null ? null : () => _forward(context, header, body),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||
@@ -114,15 +105,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
@@ -154,22 +136,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'structure',
|
||||
child: Text('Show Mail Structure'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'rfc',
|
||||
child: Text('Show Raw Email'),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
} else if (value == 'structure' && body != null) {
|
||||
_showStructure(context, body);
|
||||
} else if (value == 'rfc') {
|
||||
unawaited(_showRaw(context, header));
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -202,9 +172,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
_SafeHtml(
|
||||
data: body.htmlBody!,
|
||||
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -293,92 +263,26 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
||||
String _quotedBody(Email header, EmailBody? body) {
|
||||
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
||||
final from =
|
||||
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
||||
final rawText = body?.textBody;
|
||||
final text = (rawText != null && rawText.isNotEmpty)
|
||||
? rawText
|
||||
: await compute(htmlToPlain, body?.htmlBody ?? '');
|
||||
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
|
||||
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
||||
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(
|
||||
void _reply(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body, {
|
||||
required String to,
|
||||
required String cc,
|
||||
}) async {
|
||||
required bool replyAll,
|
||||
}) {
|
||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||
? header.subject!
|
||||
: 'Re: ${header.subject ?? ''}';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
@@ -386,61 +290,23 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
'replyToEmailId': widget.emailId,
|
||||
'prefillTo': to,
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': quoted,
|
||||
'prefillBody': _quotedBody(header, body),
|
||||
if (cc.isNotEmpty) 'prefillCc': cc,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
|
||||
|
||||
if (junk == null) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No Junk folder found')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.moveEmail(widget.emailId, junk.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: junk.path,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _forward(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body,
|
||||
) async {
|
||||
void _forward(BuildContext context, Email header, EmailBody? body) {
|
||||
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
||||
? header.subject!
|
||||
: 'Fwd: ${header.subject ?? ''}';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
extra: {
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': quoted,
|
||||
'prefillBody': _quotedBody(header, body),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -520,7 +386,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Snoozed until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||
),
|
||||
@@ -530,121 +395,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRaw(BuildContext context, Email? header) async {
|
||||
final String raw;
|
||||
try {
|
||||
raw = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.fetchRawRfc822(widget.emailId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Raw Email'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fmtSize(raw.length),
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(ctx).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
raw,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: raw));
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(content: Text('Copied to clipboard')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _downloadRaw(ctx, header, raw);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadRaw(
|
||||
BuildContext context,
|
||||
Email? header,
|
||||
String raw,
|
||||
) async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final subject = (header?.subject ?? 'email')
|
||||
.replaceAll(RegExp(r'[^\w\s-]'), '_')
|
||||
.trim();
|
||||
final filename = '$subject.eml';
|
||||
final file = File('${dir.path}/$filename');
|
||||
await file.writeAsString(raw);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Saved $filename'),
|
||||
action: SnackBarAction(
|
||||
label: 'Share',
|
||||
onPressed: () => SharePlus.instance.share(
|
||||
ShareParams(files: [XFile(file.path)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showHeaders(BuildContext context, EmailBody body) {
|
||||
if (body.headers.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('No headers available. Try re-syncing the email.'),
|
||||
),
|
||||
);
|
||||
@@ -698,176 +452,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStructure(BuildContext context, EmailBody body) {
|
||||
final tree = body.mimeTree;
|
||||
if (tree == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Structure not available. Try re-syncing the email.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final rows = <_MimeRow>[];
|
||||
_flattenMimeTree(tree, 0, rows);
|
||||
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
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];
|
||||
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(
|
||||
children: [
|
||||
SizedBox(width: row.depth * 16.0),
|
||||
Expanded(
|
||||
child: Text(
|
||||
row.label,
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Placement { to, cc, skip }
|
||||
|
||||
class _Candidate {
|
||||
_Candidate(this.address, this.placement);
|
||||
final EmailAddress address;
|
||||
_Placement placement;
|
||||
}
|
||||
|
||||
class _ReplyAllDialog extends StatefulWidget {
|
||||
const _ReplyAllDialog({required this.candidates});
|
||||
final List<_Candidate> candidates;
|
||||
|
||||
@override
|
||||
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
||||
}
|
||||
|
||||
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||
late final List<_Candidate> _candidates;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_candidates = [
|
||||
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Reply All'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final c in _candidates)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.address.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SegmentedButton<_Placement>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _Placement.to,
|
||||
label: Text('To'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.cc,
|
||||
label: Text('Cc'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.skip,
|
||||
label: Text('Skip'),
|
||||
),
|
||||
],
|
||||
selected: {c.placement},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => c.placement = s.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _candidates),
|
||||
child: const Text('Reply'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MimeRow {
|
||||
const _MimeRow(this.depth, this.label);
|
||||
final int depth;
|
||||
final String label;
|
||||
}
|
||||
|
||||
void _flattenMimeTree(MimePart part, int depth, List<_MimeRow> out) {
|
||||
final parts = <String>[part.contentType];
|
||||
if (part.filename != null) parts.add('"${part.filename}"');
|
||||
if (part.size != null) parts.add(fmtSize(part.size!));
|
||||
if (part.encoding != null) parts.add(part.encoding!);
|
||||
out.add(_MimeRow(depth, parts.join(' ')));
|
||||
for (final child in part.children) {
|
||||
_flattenMimeTree(child, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a List-Unsubscribe header and returns the first usable URI.
|
||||
@@ -902,3 +486,70 @@ class _UnsubscribeChip extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders [Html] and falls back to an error message if the widget throws
|
||||
/// during build, preventing a malformed body from crashing the whole screen.
|
||||
class _SafeHtml extends StatefulWidget {
|
||||
const _SafeHtml({required this.data, required this.extensions});
|
||||
final String data;
|
||||
final List<HtmlExtension> extensions;
|
||||
|
||||
@override
|
||||
State<_SafeHtml> createState() => _SafeHtmlState();
|
||||
}
|
||||
|
||||
class _SafeHtmlState extends State<_SafeHtml> {
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_failed) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('Message body could not be rendered.')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Intercept any build-phase throw from flutter_html for this subtree.
|
||||
// We save/restore via postFrameCallback so other widgets are unaffected.
|
||||
final prev = ErrorWidget.builder;
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||||
ErrorWidget.builder = prev;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _failed = true);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
};
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ErrorWidget.builder = prev,
|
||||
);
|
||||
|
||||
return Html(data: widget.data, extensions: widget.extensions);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -15,14 +15,6 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
// Cache formatted dates by local calendar day so DateFormat.format is called
|
||||
// at most once per unique date rather than once per list item per rebuild.
|
||||
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 EmailListScreen extends ConsumerStatefulWidget {
|
||||
const EmailListScreen({
|
||||
@@ -94,16 +86,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
_selectedSearchIds.clear();
|
||||
});
|
||||
|
||||
void _selectAll() {
|
||||
setState(() {
|
||||
if (_searching) {
|
||||
_selectedSearchIds.addAll(_searchResults?.map((e) => e.id) ?? []);
|
||||
} else {
|
||||
_selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSearchSelection(String emailId) {
|
||||
setState(() {
|
||||
if (_selectedSearchIds.contains(emailId)) {
|
||||
@@ -188,13 +170,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
? Text('$selectionCount selected')
|
||||
: Text(widget.mailboxPath),
|
||||
actions: _selecting
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
tooltip: 'Select all',
|
||||
onPressed: _selectAll,
|
||||
),
|
||||
]
|
||||
? []
|
||||
: [
|
||||
accountAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
@@ -261,9 +237,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…'
|
||||
@@ -290,10 +266,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Sync failed: $e'),
|
||||
),
|
||||
SnackBar(content: Text('Sync failed: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -350,7 +323,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
Widget _buildSyncErrorBanner() {
|
||||
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
|
||||
final error = errorAsync.value;
|
||||
final error = errorAsync.valueOrNull;
|
||||
if (error == null || error == _dismissedError) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -373,12 +346,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push(
|
||||
'/accounts/${widget.accountId}/sync-log',
|
||||
),
|
||||
child: const Text('View log'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _dismissedError = error),
|
||||
child: const Text('Dismiss'),
|
||||
@@ -430,12 +397,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
if (mailbox == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(notFoundMessage),
|
||||
),
|
||||
);
|
||||
).showSnackBar(SnackBar(content: Text(notFoundMessage)));
|
||||
return;
|
||||
}
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
@@ -466,36 +428,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
Future<void> _batchArchive() =>
|
||||
_batchMoveToRole('archive', 'No archive folder found');
|
||||
|
||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||
if (!mounted || !_searching) return;
|
||||
final query = _searchController.text.trim();
|
||||
final remaining = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query);
|
||||
if (!mounted) return;
|
||||
if (remaining.isEmpty) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
_searchController.clear();
|
||||
}
|
||||
} else {
|
||||
setState(() => _searchResults = remaining);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSearchResultAndRefresh(String emailId) async {
|
||||
await context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(emailId)}',
|
||||
);
|
||||
await _refreshSearchAndPopIfEmpty();
|
||||
}
|
||||
|
||||
Future<void> _batchDelete() async {
|
||||
final ids = _selectedEmailIds;
|
||||
final wasSearching = _searching;
|
||||
_clearSelection();
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
@@ -522,25 +456,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
|
||||
if (wasSearching && mounted) {
|
||||
// Filter deleted emails out of the local results immediately.
|
||||
// Calling searchEmails here would hit the IMAP server, which still has
|
||||
// the emails because the delete is only enqueued — not yet applied.
|
||||
final deletedIds = ids.toSet();
|
||||
final remaining = (_searchResults ?? [])
|
||||
.where((e) => !deletedIds.contains(e.id))
|
||||
.toList();
|
||||
if (remaining.isEmpty) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
_searchController.clear();
|
||||
}
|
||||
} else {
|
||||
setState(() => _searchResults = remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchMarkSpam() =>
|
||||
@@ -640,7 +555,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||
),
|
||||
@@ -727,7 +641,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_fmtDate(t.latestDate),
|
||||
_dateFmt.format(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -843,7 +757,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
),
|
||||
onTap: _selecting
|
||||
? () => _toggleSearchSelection(e.id)
|
||||
: () => unawaited(_openSearchResultAndRefresh(e.id)),
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
onLongPress: () => _toggleSearchSelection(e.id),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,16 +10,6 @@ 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 {
|
||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||
});
|
||||
|
||||
/// Returns true if [text] contains a word that starts with [query].
|
||||
/// "foo" matches "foobar" or "My Foobar" but NOT "blafoo".
|
||||
bool _hasWordPrefix(String text, String query) =>
|
||||
RegExp(r'\b' + RegExp.escape(query), caseSensitive: false).hasMatch(text);
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key, this.accountId});
|
||||
final String? accountId;
|
||||
@@ -30,24 +20,13 @@ class SearchScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
Timer? _debounce;
|
||||
_SearchResults? _results;
|
||||
bool _loading = false;
|
||||
bool _fieldFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(() {
|
||||
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -66,12 +45,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
|
||||
Future<void> _search(String query) async {
|
||||
setState(() => _loading = true);
|
||||
unawaited(
|
||||
ref
|
||||
.read(searchHistoryRepositoryProvider)
|
||||
.saveSearch(query)
|
||||
.then((_) => ref.invalidate(_searchHistoryProvider)),
|
||||
);
|
||||
try {
|
||||
final emailRepo = ref.read(emailRepositoryProvider);
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
@@ -84,7 +57,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
).wait;
|
||||
|
||||
final matchedMailboxes = allMailboxes
|
||||
.where((m) => _hasWordPrefix(m.name, ql))
|
||||
.where((m) => m.name.toLowerCase().contains(ql))
|
||||
.toList()
|
||||
..sort(compareMailboxes);
|
||||
|
||||
@@ -96,9 +69,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
for (final addr in [...email.from, ...email.to, ...email.cc]) {
|
||||
final key = '${email.accountId}:${addr.email}';
|
||||
if (seen.contains(key)) continue;
|
||||
final matchesEmail = _hasWordPrefix(addr.email, ql);
|
||||
final matchesName =
|
||||
addr.name != null && _hasWordPrefix(addr.name!, ql);
|
||||
final matchesEmail = addr.email.toLowerCase().contains(ql);
|
||||
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
|
||||
if (!matchesEmail && !matchesName) continue;
|
||||
seen.add(key);
|
||||
final addrEmail = addr.email;
|
||||
@@ -140,7 +112,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search folders, addresses, emails…',
|
||||
@@ -166,9 +137,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
Widget _buildBody() {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_results == null) {
|
||||
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||
return _buildHistoryPanel();
|
||||
}
|
||||
return const Center(child: Text('Type 3+ characters to search'));
|
||||
}
|
||||
final r = _results!;
|
||||
@@ -201,66 +169,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryPanel() {
|
||||
final history = ref.watch(_searchHistoryProvider);
|
||||
return history.when(
|
||||
loading: () => const Center(child: Text('Type 3+ characters to search')),
|
||||
error: (_, __) =>
|
||||
const Center(child: Text('Type 3+ characters to search')),
|
||||
data: (terms) {
|
||||
if (terms.isEmpty) {
|
||||
return const Center(child: Text('Type 3+ characters to search'));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Recent searches',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(searchHistoryRepositoryProvider)
|
||||
.clearHistory();
|
||||
ref.invalidate(_searchHistoryProvider);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final term in terms)
|
||||
ActionChip(
|
||||
label: Text(term),
|
||||
onPressed: () {
|
||||
_ctrl.text = term;
|
||||
_ctrl.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: term.length),
|
||||
);
|
||||
unawaited(_search(term));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchResults {
|
||||
|
||||
@@ -11,7 +11,6 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
super.key,
|
||||
required this.accountId,
|
||||
this.script,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
final String accountId;
|
||||
@@ -19,9 +18,6 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
/// Null when creating a new script.
|
||||
final SieveScript? script;
|
||||
|
||||
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
ConsumerState<SieveScriptEditScreen> createState() =>
|
||||
_SieveScriptEditScreenState();
|
||||
@@ -54,13 +50,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
Future<void> _loadContent() async {
|
||||
setState(() => _loadingContent = true);
|
||||
try {
|
||||
final content = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
final content = await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
if (mounted) {
|
||||
_contentController.text = content;
|
||||
setState(() => _loadingContent = false);
|
||||
@@ -86,21 +78,12 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
if (widget.isLocal) {
|
||||
await ref.read(localSieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
} else {
|
||||
await ref.read(sieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
}
|
||||
await ref.read(sieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -8,17 +8,10 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class SieveScriptsScreen extends ConsumerStatefulWidget {
|
||||
const SieveScriptsScreen({
|
||||
super.key,
|
||||
required this.accountId,
|
||||
this.isLocal = false,
|
||||
});
|
||||
const SieveScriptsScreen({super.key, required this.accountId});
|
||||
|
||||
final String accountId;
|
||||
|
||||
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
|
||||
}
|
||||
@@ -28,10 +21,6 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
String get _editRoute => widget.isLocal
|
||||
? '/accounts/${widget.accountId}/sieve/local/edit'
|
||||
: '/accounts/${widget.accountId}/sieve/edit';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -44,13 +33,8 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final scripts = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.listScripts(widget.accountId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.listScripts(widget.accountId);
|
||||
final scripts =
|
||||
await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scripts = scripts;
|
||||
@@ -69,24 +53,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
|
||||
Future<void> _activate(SieveScript script) async {
|
||||
try {
|
||||
if (widget.isLocal) {
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
}
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to activate: $e'),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,24 +86,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
);
|
||||
if (!(confirmed ?? false) || !mounted) return;
|
||||
try {
|
||||
if (widget.isLocal) {
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
}
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to delete: $e'),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,15 +102,11 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||
),
|
||||
),
|
||||
appBar: AppBar(title: const Text('Email filters')),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await context.push(_editRoute);
|
||||
await context.push('/accounts/${widget.accountId}/sieve/edit');
|
||||
await _load();
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
@@ -172,69 +134,22 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
);
|
||||
}
|
||||
final scripts = _scripts ?? [];
|
||||
return Column(
|
||||
children: [
|
||||
_SieveSourceBanner(isLocal: widget.isLocal),
|
||||
Expanded(
|
||||
child: scripts.isEmpty
|
||||
? const Center(
|
||||
child: Text('No filters yet. Tap + to create one.'),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: scripts.length,
|
||||
itemBuilder: (ctx, i) => _ScriptTile(
|
||||
script: scripts[i],
|
||||
accountId: widget.accountId,
|
||||
editRoute: _editRoute,
|
||||
onActivate: () => _activate(scripts[i]),
|
||||
onDelete: () => _delete(scripts[i]),
|
||||
onEdited: _load,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (scripts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No Sieve scripts. Tap + to create one.'),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: scripts.length,
|
||||
itemBuilder: (ctx, i) => _ScriptTile(
|
||||
script: scripts[i],
|
||||
accountId: widget.accountId,
|
||||
onActivate: () => _activate(scripts[i]),
|
||||
onDelete: () => _delete(scripts[i]),
|
||||
onEdited: _load,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SieveSourceBanner extends StatelessWidget {
|
||||
const _SieveSourceBanner({required this.isLocal});
|
||||
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = isLocal
|
||||
? 'Local Filters run Sieve scripts directly on this device. '
|
||||
'Remote Filters, which run on the mail server, are configured separately.'
|
||||
: 'Remote Filters run Sieve scripts on the mail server '
|
||||
'(ManageSieve or JMAP). '
|
||||
'Local Filters, which run on this device, are configured separately.';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isLocal ? Icons.phone_android : Icons.dns,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -244,7 +159,6 @@ class _ScriptTile extends StatelessWidget {
|
||||
const _ScriptTile({
|
||||
required this.script,
|
||||
required this.accountId,
|
||||
required this.editRoute,
|
||||
required this.onActivate,
|
||||
required this.onDelete,
|
||||
required this.onEdited,
|
||||
@@ -252,7 +166,6 @@ class _ScriptTile extends StatelessWidget {
|
||||
|
||||
final SieveScript script;
|
||||
final String accountId;
|
||||
final String editRoute;
|
||||
final VoidCallback onActivate;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onEdited;
|
||||
@@ -270,7 +183,10 @@ class _ScriptTile extends StatelessWidget {
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _ScriptAction.edit:
|
||||
await context.push(editRoute, extra: script);
|
||||
await context.push(
|
||||
'/accounts/$accountId/sieve/edit',
|
||||
extra: script,
|
||||
);
|
||||
onEdited();
|
||||
case _ScriptAction.activate:
|
||||
onActivate();
|
||||
@@ -293,7 +209,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
await context.push(editRoute, extra: script);
|
||||
await context.push('/accounts/$accountId/sieve/edit', extra: script);
|
||||
onEdited();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
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');
|
||||
|
||||
String _fmtDuration(Duration d) {
|
||||
final ms = d.inMilliseconds;
|
||||
return ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
||||
}
|
||||
|
||||
String _fmtBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
@@ -25,57 +16,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 +64,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,35 +91,27 @@ 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) {
|
||||
final durationLabel = _fmtDuration(entry.duration);
|
||||
final ms = entry.duration.inMilliseconds;
|
||||
final durationLabel =
|
||||
ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
||||
final proto =
|
||||
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
||||
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 +122,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),
|
||||
@@ -266,10 +154,7 @@ class _SyncLogTile extends StatelessWidget {
|
||||
for (final m in entry.mailboxStats)
|
||||
_row(
|
||||
' ${m.mailboxPath}',
|
||||
[
|
||||
'${m.fetched} new · ${m.skipped} up-to-date',
|
||||
if (m.duration != null) _fmtDuration(m.duration!),
|
||||
].join(' · '),
|
||||
'${m.fetched} new · ${m.skipped} up-to-date',
|
||||
),
|
||||
],
|
||||
if (entry.errorMessage != null)
|
||||
@@ -280,31 +165,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),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -9,7 +10,6 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||
|
||||
@@ -163,9 +163,11 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
Html(
|
||||
data: body.htmlBody!,
|
||||
extensions: [
|
||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||
],
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -246,7 +248,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (confirmed == true) {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
// Fetch data first for IMAP undo support
|
||||
@@ -254,7 +255,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
|
||||
final destPath = await repo.deleteEmail(widget.email.id);
|
||||
|
||||
if (!mounted) return;
|
||||
if (original != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
@@ -273,3 +273,19 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -86,12 +86,7 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
),
|
||||
);
|
||||
).showSnackBar(const SnackBar(content: Text('Action undone.')));
|
||||
}
|
||||
},
|
||||
child: const Text('Undo'),
|
||||
|
||||
@@ -1,75 +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)}';
|
||||
@@ -70,21 +70,13 @@ class FolderDrawer extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.dns),
|
||||
title: const Text('Remote Filters'),
|
||||
leading: const Icon(Icons.filter_list),
|
||||
title: const Text('Email filters'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/$accountId/sieve'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.phone_android),
|
||||
title: const Text('Local Filters'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/$accountId/sieve/local'));
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: StreamBuilder(
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Builds the full HTML document string for rendering an email body.
|
||||
///
|
||||
/// Forces `color-scheme: light` so that emails with black text remain readable
|
||||
/// when the device is in dark mode — the WebView would otherwise apply a dark
|
||||
/// background while leaving the email's own text colours unchanged.
|
||||
@visibleForTesting
|
||||
String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||
final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
|
||||
// script-src 'none' blocks page scripts; JS mode stays unrestricted so the
|
||||
// controller can call runJavaScriptReturningResult for height measurement.
|
||||
const cspBase = "default-src 'none'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"script-src 'none'; "
|
||||
"object-src 'none'; "
|
||||
"font-src 'none'";
|
||||
final csp = '$cspBase; img-src $imgSrc';
|
||||
|
||||
return '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #1976D2; }
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
$htmlBody
|
||||
</body>
|
||||
</html>''';
|
||||
}
|
||||
|
||||
/// Renders an HTML email body securely.
|
||||
///
|
||||
/// On Android the content is displayed in a WebView with JavaScript blocked
|
||||
/// via CSP and remote images blocked until the user opts in. Link taps show
|
||||
/// a confirmation dialog that highlights the registered domain to aid phishing
|
||||
/// detection.
|
||||
///
|
||||
/// On Linux (where webview_flutter has no platform support) the HTML is
|
||||
/// converted to plain text and shown in a [SelectableText] widget.
|
||||
class SecureEmailWebView extends StatefulWidget {
|
||||
const SecureEmailWebView({
|
||||
super.key,
|
||||
required this.htmlBody,
|
||||
this.loadRemoteImages = false,
|
||||
});
|
||||
|
||||
final String htmlBody;
|
||||
final bool loadRemoteImages;
|
||||
|
||||
@override
|
||||
State<SecureEmailWebView> createState() => _SecureEmailWebViewState();
|
||||
}
|
||||
|
||||
class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
// Null on Linux where WebView is unavailable.
|
||||
WebViewController? _controller;
|
||||
double _height = 300;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!Platform.isLinux) {
|
||||
final c = WebViewController();
|
||||
unawaited(c.setJavaScriptMode(JavaScriptMode.unrestricted));
|
||||
unawaited(c.setBackgroundColor(Colors.transparent));
|
||||
unawaited(
|
||||
c.setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _handleNavigation,
|
||||
onPageFinished: _measureHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
unawaited(c.loadHtmlString(_buildHtml()));
|
||||
_controller = c;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SecureEmailWebView old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.htmlBody != widget.htmlBody ||
|
||||
old.loadRemoteImages != widget.loadRemoteImages) {
|
||||
if (_controller != null) {
|
||||
unawaited(_controller!.loadHtmlString(_buildHtml()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildHtml() => buildEmailHtml(
|
||||
widget.htmlBody,
|
||||
loadRemoteImages: widget.loadRemoteImages,
|
||||
);
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
}
|
||||
|
||||
NavigationDecision _handleNavigation(NavigationRequest req) {
|
||||
final url = req.url;
|
||||
if (url == 'about:blank' || url.startsWith('data:')) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
unawaited(_showLinkDialog(url));
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _showLinkDialog(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !mounted) return;
|
||||
|
||||
final host = uri.host;
|
||||
final parts = host.split('.');
|
||||
// Bold the registered domain (last two DNS labels) to aid phishing detection.
|
||||
final boldStart = (parts.length >= 2
|
||||
? host.length -
|
||||
parts.last.length -
|
||||
1 -
|
||||
parts[parts.length - 2].length
|
||||
: 0)
|
||||
.clamp(0, host.length);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Open link?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
children: [
|
||||
TextSpan(text: host.substring(0, boldStart)),
|
||||
TextSpan(
|
||||
text: host.substring(boldStart),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
url,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Open in browser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final launched =
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open: $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Linux has no webview_flutter platform implementation — show plain text.
|
||||
if (Platform.isLinux) {
|
||||
return SelectableText(
|
||||
htmlToPlain(widget.htmlBody),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: _height,
|
||||
child: WebViewWidget(controller: _controller!),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,7 @@ class UndoShell extends ConsumerWidget {
|
||||
ref.listen<List<UndoAction>>(undoServiceProvider, (previous, next) {
|
||||
if (next.isNotEmpty &&
|
||||
(previous == null || previous.length < next.length)) {
|
||||
final action = next.last;
|
||||
// Don't show a snackbar for actions loaded from persistence on app
|
||||
// startup — only for actions pushed in this session.
|
||||
if (DateTime.now().difference(action.timestamp).inSeconds < 30) {
|
||||
_showUndoSnackbar(context, ref, action);
|
||||
}
|
||||
_showUndoSnackbar(context, ref, next.last);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,7 +29,6 @@ class UndoShell extends ConsumerWidget {
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
action.type == UndoType.delete
|
||||
? '${action.emailIds.length} email(s) moved to Trash'
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# SharedInbox — Improvement Plan
|
||||
|
||||
30 tasks across 7 perspectives. Priority markers: 🔴 high · 🟡 medium · 🟢 nice-to-have.
|
||||
|
||||
---
|
||||
|
||||
## Group 1: Performance
|
||||
|
||||
### P1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/41
|
||||
|
||||
### P1 🔴 Replace LIKE-based search with FTS5 virtual table
|
||||
The current `observeEmails` and search queries use `LIKE '%query%'` which becomes a full-table scan at scale.
|
||||
Create an `email_fts` FTS5 virtual table (subject, preview, fromJson) populated via trigger or sync-time insert.
|
||||
Wire `SearchScreen` to query the FTS table instead.
|
||||
Files: `lib/data/db/database.dart`, `lib/data/repositories/email_repository_impl.dart`.
|
||||
|
||||
### P2 🔴 Lazy-load email bodies on scroll (pagination)
|
||||
`observeThreads` and `observeEmails` return the full list with no limit. As the mailbox grows this streams thousands of rows into memory.
|
||||
Add a page-size parameter (e.g. 50) with "load more" support in `EmailListScreen`.
|
||||
The `EmailBodies` table is already separate — never fetch bodies in the list query.
|
||||
Files: `lib/data/repositories/email_repository_impl.dart`, `lib/ui/screens/email_list_screen.dart`.
|
||||
|
||||
### P3 🟡 Defer HTML parsing off the UI thread using an Isolate
|
||||
`flutter_html` parsing blocks the raster thread for large HTML bodies, causing jank when opening email detail.
|
||||
Move the HTML→Widget tree conversion (or at minimum the `html_utils.dart` HTML-to-plain step) into a `compute()` call.
|
||||
Files: `lib/ui/screens/email_detail_screen.dart`, `lib/core/utils/html_utils.dart`.
|
||||
|
||||
### P4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/36
|
||||
|
||||
### P5 🟢 Cache the formatted date strings in EmailListScreen
|
||||
`DateFormat('MMM d').format(...)` is called for every email on every rebuild. Compute and cache these in the model layer or inside the list item widget's `build` method using a static cache map.
|
||||
Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/utils/format_utils.dart`.
|
||||
|
||||
---
|
||||
|
||||
## Group 2: Reliability & Resilience
|
||||
|
||||
### R1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/20
|
||||
|
||||
### R2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/22
|
||||
|
||||
### R3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/35
|
||||
|
||||
### R4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/23
|
||||
|
||||
### R5 🟡 Handle TLS certificate changes gracefully
|
||||
`tls_error.dart` detects TLS errors but they bubble up as generic errors in the sync loop.
|
||||
Detect `TlsError` specifically in `_AccountSync` and show a user-facing dialog offering to re-add the account or trust the new certificate.
|
||||
Files: `lib/data/imap/tls_error.dart`, `lib/core/sync/account_sync_manager.dart`.
|
||||
|
||||
### R6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/24
|
||||
|
||||
---
|
||||
|
||||
## Group 3: Security
|
||||
|
||||
### S1 🔴 Optional SQLCipher encryption for the Drift database
|
||||
Emails cached locally are plaintext. Users on shared or rooted devices are exposed.
|
||||
Add an opt-in "Encrypt local storage" setting using `drift`'s `encrypted` backend (`sqflite_cipher` / `sqlcipher_flutter_libs`).
|
||||
Store the database key in `flutter_secure_storage` (already present).
|
||||
Files: `lib/data/db/database.dart`, `pubspec.yaml`, a new settings toggle.
|
||||
|
||||
### S2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/25
|
||||
|
||||
### S3 🟡 Enforce certificate pinning for known providers (opt-in)
|
||||
Auto-discovered accounts for major providers (Gmail, Fastmail, Proton) could be pinned to their known CA hierarchy.
|
||||
Implement as an opt-in per-account setting; only applies when the account is auto-discovered via `AccountDiscoveryService`.
|
||||
Files: `lib/core/services/account_discovery_service.dart`, `lib/data/imap/imap_client_factory.dart`.
|
||||
|
||||
### S4 🟢 Audit and restrict external link handling in HTML emails
|
||||
`flutter_html` passes `<a href>` clicks to `url_launcher` without a prompt.
|
||||
Before launching, show a confirmation dialog with the destination URL so phishing links are visible.
|
||||
Files: `lib/ui/screens/email_detail_screen.dart`.
|
||||
|
||||
---
|
||||
|
||||
## Group 4: User Experience
|
||||
|
||||
### U1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/26
|
||||
|
||||
### U2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/27
|
||||
|
||||
### U3 🟡 Add "Recent searches" history to SearchScreen
|
||||
The search bar clears on navigation. Store the last 10 search terms in a local DB table and show them as chips below the search field when the field is focused but empty.
|
||||
Files: `lib/ui/screens/search_screen.dart`, `lib/data/db/database.dart`.
|
||||
|
||||
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
|
||||
|
||||
### U5 — Already implemented (Dismissible archive/delete swipes with undo, found in email_list_screen.dart)
|
||||
|
||||
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
|
||||
|
||||
### U7 🟢 Onboarding walkthrough for first-time users
|
||||
The app opens directly to an empty account list with only a `+` button. First-time users have no guidance.
|
||||
Add a one-time welcome card or bottom-sheet with the three-step flow: Add account → wait for sync → open inbox.
|
||||
Files: `lib/ui/screens/account_list_screen.dart`.
|
||||
|
||||
### U8 🟢 "Mark all as read" action in mailbox
|
||||
Power users managing high-volume mailboxes need bulk read marking. Add a "Mark all as read" option in the mailbox overflow menu.
|
||||
Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/repositories/email_repository.dart`, `lib/data/repositories/email_repository_impl.dart`.
|
||||
|
||||
---
|
||||
|
||||
## Group 5: Testing
|
||||
|
||||
### T1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/30
|
||||
|
||||
### T2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/31
|
||||
|
||||
### T3 🟡 Contract tests for all Repository interfaces
|
||||
The interfaces in `core/repositories/` have no shared contract test suite. Concrete impls can silently diverge.
|
||||
Add a shared `EmailRepositoryContract` abstract test class; run it against both `EmailRepositoryImpl` and any future mock/fake. Mirror this for `MailboxRepository` and `AccountRepository`.
|
||||
Files: `test/unit/` (new contract test files).
|
||||
|
||||
### T4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/32
|
||||
|
||||
### T5 🟢 Snapshot / golden tests for key email list states
|
||||
The email list has multiple states: loading, empty, normal, selection mode, search active, error banner.
|
||||
Add golden tests using `matchesGoldenFile` for each state so visual regressions surface in CI.
|
||||
Files: `test/widget/email_list_screen_test.dart`.
|
||||
|
||||
---
|
||||
|
||||
## Group 6: Architecture & Code Quality
|
||||
|
||||
### A1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/39
|
||||
|
||||
### A2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/33
|
||||
|
||||
### A3 🟡 Make AccountSyncManager testable without real IMAP connections
|
||||
`AccountSyncManager` accepts `ImapConnectFn` as a dependency but `_JmapAccountSync` constructs its HTTP client internally.
|
||||
Pass an injectable `http.Client` to `_JmapAccountSync` (already done in `EmailRepositoryImpl`; mirror the pattern here).
|
||||
Files: `lib/core/sync/account_sync_manager.dart`, `test/unit/account_sync_manager_test.dart`.
|
||||
|
||||
### A4 🟡 Replace raw JSON strings in DB with structured encoding
|
||||
`fromJson`, `toAddresses`, `ccJson`, `references` are stored as raw JSON strings parsed on every model conversion.
|
||||
Create typed value classes with `fromJson`/`toJson` in `core/models/email.dart` and add a `TypeConverter` in the Drift schema so the DB layer owns the serialisation.
|
||||
Files: `lib/data/db/database.dart`, `lib/core/models/email.dart`, `lib/data/repositories/email_repository_impl.dart`.
|
||||
|
||||
### A5 🟢 Enforce layer boundaries via lint custom rules or barrel imports
|
||||
The `ui/` layer directly imports `data/` concrete classes in several screens (e.g. `drift` types leak through).
|
||||
Add a custom `analysis_options.yaml` rule or a CI lint step that flags any `ui/` import of `data/` (only `core/` interfaces are allowed from UI).
|
||||
Files: `analysis_options.yaml`, CI config.
|
||||
|
||||
---
|
||||
|
||||
## Group 7: Developer Experience
|
||||
|
||||
### D1 🔴 CI matrix for macOS and Windows builds
|
||||
The CI currently tests Linux and Android. The macOS and Windows targets are "scaffolded" and may have accumulated silent breakage.
|
||||
Add `flutter build macos --debug` and `flutter build windows --debug` jobs to the CI workflow with the same failure threshold as Linux.
|
||||
Files: `.github/workflows/ci.yml` (or Codeberg equivalent).
|
||||
|
||||
### D2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/34
|
||||
|
||||
### D3 🟢 Document the sync protocol in a SYNC.md architecture doc
|
||||
`DB-SYNC.md` exists but focuses on the DB schema. The IMAP IDLE loop, exponential backoff, pending-change queue, and undo cancel logic are spread across four files with no single reference.
|
||||
Write `SYNC.md` that describes the full lifecycle of an email action from UI tap to server confirmation.
|
||||
Files: `SYNC.md` (new).
|
||||
-1369
File diff suppressed because it is too large
Load Diff
+13
-33
@@ -19,15 +19,15 @@ dependencies:
|
||||
|
||||
# Local persistence (offline-first)
|
||||
drift: ^2.20.3
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
sqlite3_flutter_libs: ^0.5.28
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
|
||||
# State management
|
||||
flutter_riverpod: ^3.0.0
|
||||
flutter_riverpod: ^2.6.1
|
||||
|
||||
# Navigation
|
||||
go_router: ^17.2.3
|
||||
go_router: ^14.8.1
|
||||
|
||||
# Secure credential storage (passwords)
|
||||
flutter_secure_storage: ^10.0.0
|
||||
@@ -36,46 +36,31 @@ dependencies:
|
||||
intl: any
|
||||
|
||||
# File picking (compose attachments) and opening downloaded attachments
|
||||
file_picker: ^12.0.0-beta.4
|
||||
file_picker: ^8.0.0
|
||||
open_filex: ^4.6.0
|
||||
mime: ^2.0.0
|
||||
|
||||
# QR code generation for account sharing
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
# Public-key encryption for secure account sharing (ECIES: X25519 + AES-256-GCM)
|
||||
cryptography: ^2.7.0
|
||||
|
||||
# QR code scanning (camera) for secure account import
|
||||
mobile_scanner: ^7.2.0
|
||||
|
||||
# HTML rendering for email bodies
|
||||
webview_flutter: ^4.0.0
|
||||
flutter_html: ^3.0.0
|
||||
url_launcher: ^6.3.2
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
flutter_markdown: ^0.7.7+1
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
mockito: ^5.4.4
|
||||
fake_async: ^1.3.1
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
sqlite3: any # used directly in test/unit/db_test_helper.dart
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
@@ -85,12 +70,7 @@ flutter:
|
||||
- assets/
|
||||
|
||||
dependency_overrides:
|
||||
# path_provider_android 2.2.21 updated to Pigeon 26, which causes a
|
||||
# channel-error on startup on some Android devices. 2.3+ uses package:jni
|
||||
# (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"
|
||||
# path_provider_android 2.3+ uses package:jni which crashes on startup
|
||||
# (SIGSEGV in libdartjni.so FindClassUnchecked — JNI env not ready when
|
||||
# the Dart VM first calls into it). Pin to 2.2.x which uses Pigeon instead.
|
||||
path_provider_android: ">=2.2.0 <2.3.0"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
}
|
||||
}
|
||||
@@ -1,991 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
agent_loop.py — called from cron every 10 minutes.
|
||||
|
||||
Flow
|
||||
----
|
||||
1. Agent already running?
|
||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
|
||||
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||
d. Main CI running → save pending-ci state, exit 0
|
||||
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||
section 2b always returns first)
|
||||
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
|
||||
save state, exit 0
|
||||
h. No ToPlan issues → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
i. No Ready issues → print "nothing to do", exit 0
|
||||
|
||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||
Plan agents must NOT write any code or create PRs; they only post a plan comment.
|
||||
|
||||
State file: ~/.sharedinbox-agent-state.json
|
||||
{ "pid": 12345, "issue": 91,
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue|plan|ci-fix|pending-ci" }
|
||||
|
||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||
To resume the Claude conversation, look up the session UUID first:
|
||||
|
||||
scripts/agent_loop.py list # shows NAME and UUID columns
|
||||
claude --resume <uuid> # use the UUID, NOT the session name
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||
os.environ["PATH"] = (
|
||||
f"{Path.home()}/.nix-profile/bin"
|
||||
f":{Path.home()}/go/bin"
|
||||
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
|
||||
)
|
||||
|
||||
# ── configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
REPO = "guettli/sharedinbox"
|
||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
|
||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
|
||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||
"-" + str(Path.home())[1:].replace("/", "-")
|
||||
)
|
||||
|
||||
# Labels used by the workflow.
|
||||
LABEL_READY = "State/Ready"
|
||||
LABEL_IN_PROGRESS = "State/InProgress"
|
||||
LABEL_QUESTION = "State/Question"
|
||||
LABEL_PRIO_HIGH = "Prio/High"
|
||||
LABEL_TO_PLAN = "State/ToPlan"
|
||||
LABEL_PLANNED = "State/Planned"
|
||||
|
||||
# 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 _to_plan_issues() -> list[dict]:
|
||||
"""Return open issues with State/ToPlan, 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 []
|
||||
to_plan = [
|
||||
i for i in data
|
||||
if any(lbl["name"] == LABEL_TO_PLAN for lbl in i.get("labels", []))
|
||||
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
||||
]
|
||||
to_plan.sort(key=lambda i: (
|
||||
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
||||
i["number"],
|
||||
))
|
||||
return to_plan
|
||||
|
||||
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest ci.yml run on the main branch.
|
||||
|
||||
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
|
||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||
We also require workflow_id == "ci.yml".
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
if (run.get("event") == "push"
|
||||
and run.get("prettyref") == "main"
|
||||
and run.get("workflow_id") == "ci.yml"):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
"""Return the latest CI run for a specific branch, or None.
|
||||
|
||||
Forgejo's workflow_runs API has no top-level head_branch field.
|
||||
For push events the branch is in ``prettyref``; for pull_request
|
||||
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
if run.get("event") == "pull_request":
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
elif run.get("event") == "push":
|
||||
if run.get("prettyref") == branch:
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
|
||||
"""Return the first PR in the given state whose head branch matches, or None."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||
"--repo", REPO, "--state", state, "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
prs = json.loads(result.stdout)
|
||||
for pr in prs:
|
||||
head = pr.get("head", {})
|
||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
if ref == branch:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def _open_issue_prs() -> list[dict]:
|
||||
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return []
|
||||
prs = json.loads(result.stdout)
|
||||
issue_prs = []
|
||||
for pr in prs:
|
||||
head = pr.get("head", {})
|
||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
if re.match(r"^issue-\d+-fix$", ref or ""):
|
||||
issue_prs.append(pr)
|
||||
issue_prs.sort(key=lambda p: p["number"])
|
||||
return issue_prs
|
||||
|
||||
|
||||
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_issue_labels(issue: int) -> list[str]:
|
||||
"""Return label names for an issue."""
|
||||
data = _tea_get(f"repos/{REPO}/issues/{issue}")
|
||||
if not data:
|
||||
return []
|
||||
return [lbl["name"] for lbl in data.get("labels", [])]
|
||||
|
||||
|
||||
def _merge_pr(pr_number: int) -> None:
|
||||
"""Squash-merge a PR via fgj."""
|
||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||
|
||||
|
||||
def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: int | None) -> str:
|
||||
"""Handle a PR that is still open after a successful _merge_pr() call.
|
||||
|
||||
Returns one of:
|
||||
"rebase-spawned" — merge conflict detected; rebase agent started, state written
|
||||
"merged" — PR closed after a retry
|
||||
"fallback" — all options exhausted; caller should set State/Question
|
||||
"""
|
||||
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
|
||||
mergeable = (pr_data or {}).get("mergeable")
|
||||
|
||||
if mergeable is False:
|
||||
prompt = (
|
||||
f"Rebase branch `{branch}` onto main to resolve merge conflicts, then push. "
|
||||
"Do not change any logic — only resolve conflicts and push."
|
||||
)
|
||||
session_name = f"rebase-pr-{pr_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
_write_state(pid, issue_num, "pending-ci", session_name=session_name)
|
||||
print(f"PR #{pr_number} has merge conflicts — spawned rebase agent (pid={pid}).")
|
||||
return "rebase-spawned"
|
||||
|
||||
for attempt in range(1, 3):
|
||||
time.sleep(5)
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"PR #{pr_number} merge retry {attempt} failed: {e}")
|
||||
if not _find_pr_for_branch(branch):
|
||||
print(f"PR #{pr_number} merged on retry {attempt}.")
|
||||
return "merged"
|
||||
|
||||
return "fallback"
|
||||
|
||||
|
||||
# ── state file ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_state() -> dict | None:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
|
||||
data: dict = {
|
||||
"pid": pid,
|
||||
"issue": issue,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"type": kind,
|
||||
}
|
||||
if issue_title is not None:
|
||||
data["issue_title"] = issue_title
|
||||
if session_name is not None:
|
||||
data["session_name"] = session_name
|
||||
if ci_run_id is not None:
|
||||
data["ci_run_id_at_start"] = ci_run_id
|
||||
STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||
STATE_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _update_heartbeat() -> None:
|
||||
"""Record that the agent loop ran right now."""
|
||||
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
|
||||
HEARTBEAT_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _find_session_uuid(session_name: str) -> str | None:
|
||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||
|
||||
Claude stores session metadata in JSONL files; the first entry with
|
||||
type=="agent-name" contains both the human-readable name and the UUID
|
||||
needed for ``claude --resume <uuid>``.
|
||||
"""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
return None
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
|
||||
return d.get("sessionId")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _start_agent(prompt: str, session_name: str) -> int:
|
||||
"""Start Claude Code as a detached background process and return its PID."""
|
||||
log_dir = Path.home() / ".sharedinbox-agent-logs"
|
||||
log_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
log_file = log_dir / f"{session_name}-{ts}.log"
|
||||
|
||||
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"claude",
|
||||
"--dangerously-skip-permissions",
|
||||
"--name", session_name,
|
||||
"-p", prompt,
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
start_new_session=True,
|
||||
)
|
||||
log_fh.close() # Parent closes its copy; the child retains the fd.
|
||||
# Answer the workspace-trust dialog; after this the pipe hits EOF.
|
||||
proc.stdin.write(b"\n")
|
||||
proc.stdin.close()
|
||||
|
||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
|
||||
return proc.pid
|
||||
|
||||
|
||||
def _agent_alive(state: dict) -> bool:
|
||||
"""Return True if the agent process is still running."""
|
||||
pid = state.get("pid")
|
||||
if pid is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
|
||||
def _is_claude_process(pid: int) -> bool:
|
||||
"""Return True if pid's comm name indicates it is a claude/node process."""
|
||||
try:
|
||||
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||
return comm in ("claude", "node")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_age_seconds(state: dict) -> float:
|
||||
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||
try:
|
||||
started_at = datetime.fromisoformat(state["started_at"])
|
||||
return (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _git_summary() -> str:
|
||||
"""Return a one-line summary of the latest commit and whether it's been pushed."""
|
||||
try:
|
||||
commit = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
ahead = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
|
||||
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
|
||||
elif ahead.returncode == 0:
|
||||
push_status = "pushed"
|
||||
else:
|
||||
push_status = "no upstream"
|
||||
return f"{commit} [{push_status}]"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _kill_agent(state: dict) -> None:
|
||||
"""Forcefully stop the running agent."""
|
||||
pid = state.get("pid")
|
||||
if pid and _is_claude_process(pid):
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
elif pid:
|
||||
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
|
||||
|
||||
|
||||
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
"""List recent agent-loop sessions, newest first."""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||
return 0
|
||||
|
||||
sessions = []
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
agent_name = None
|
||||
session_id = None
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name":
|
||||
agent_name = d.get("agentName")
|
||||
session_id = d.get("sessionId")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if agent_name:
|
||||
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
|
||||
|
||||
if not sessions:
|
||||
print("No agent sessions found.")
|
||||
return 0
|
||||
|
||||
sessions.sort(reverse=True)
|
||||
total = len(sessions)
|
||||
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
|
||||
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
||||
for mtime, name, sid in sessions[:20]:
|
||||
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
||||
print(f" {ts:<16} {name:<20} {sid}")
|
||||
if total > 20:
|
||||
print(f" ... ({total - 20} more)")
|
||||
return 0
|
||||
|
||||
|
||||
# ── monitor subcommand ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_monitor() -> int:
|
||||
"""Check that the agent loop has run within the last 2 hours.
|
||||
|
||||
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
|
||||
Intended to be called from a scheduled CI job or cron every 2 hours.
|
||||
"""
|
||||
if not HEARTBEAT_FILE.exists():
|
||||
print(
|
||||
f"WARNING: Agent loop heartbeat file missing — "
|
||||
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
|
||||
except ValueError:
|
||||
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
|
||||
return 1
|
||||
age = (datetime.now(timezone.utc) - last_run).total_seconds()
|
||||
if age > MAX_HEARTBEAT_AGE_SECONDS:
|
||||
print(
|
||||
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
|
||||
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
|
||||
)
|
||||
return 1
|
||||
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
|
||||
return 0
|
||||
|
||||
|
||||
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_loop() -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||
_update_heartbeat()
|
||||
|
||||
state = _read_state()
|
||||
|
||||
# ── 1. Agent already running? ─────────────────────────────────────────────
|
||||
if state and _agent_alive(state):
|
||||
age = _agent_age_seconds(state)
|
||||
issue = state.get("issue")
|
||||
kind = state.get("type", "issue")
|
||||
pid = state.get("pid", "?")
|
||||
|
||||
issue_title = state.get("issue_title", "")
|
||||
issue_ref = (
|
||||
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
|
||||
)
|
||||
|
||||
if age > MAX_AGENT_AGE_SECONDS:
|
||||
print(
|
||||
f"Agent pid={pid!r} ({issue_ref}) "
|
||||
f"has been running for {age/60:.0f} min — aborting."
|
||||
)
|
||||
_kill_agent(state)
|
||||
_clear_state()
|
||||
if issue:
|
||||
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
issue,
|
||||
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
|
||||
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
print(f"Set {_issue_url(issue)} to State/Question.")
|
||||
return 1
|
||||
|
||||
session_name = state.get("session_name")
|
||||
uuid = _find_session_uuid(session_name) if session_name else None
|
||||
if uuid:
|
||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||
elif session_name:
|
||||
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
||||
else:
|
||||
resume_cmd = ""
|
||||
git_info = _git_summary()
|
||||
parts = [
|
||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||
]
|
||||
if resume_cmd:
|
||||
parts.append(f" Resume: {resume_cmd}")
|
||||
if git_info:
|
||||
parts.append(f" Commit: {git_info}")
|
||||
print("\n".join(parts))
|
||||
return 0
|
||||
|
||||
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||
pending_issue: int | None = None
|
||||
pending_type: str | None = None
|
||||
ci_run_id_at_start: int | None = None
|
||||
if state:
|
||||
pending_issue = state.get("issue")
|
||||
pending_type = state.get("type")
|
||||
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||
_clear_state()
|
||||
|
||||
# ── 2a. Finished planning agent ───────────────────────────────────────────
|
||||
if pending_issue and pending_type == "plan":
|
||||
session_name = f"plan-issue-{pending_issue}"
|
||||
uuid = _find_session_uuid(session_name)
|
||||
if uuid:
|
||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_PLANNED], remove=[LABEL_IN_PROGRESS])
|
||||
print(f"Planning done for {_issue_url(pending_issue)} — set State/Planned.")
|
||||
return 0
|
||||
|
||||
# ── 2b. Check for a PR opened by the agent ───────────────────────────────
|
||||
if pending_issue:
|
||||
branch = f"issue-{pending_issue}-fix"
|
||||
pr = _find_pr_for_branch(branch)
|
||||
if pr:
|
||||
pr_number = pr["number"]
|
||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
|
||||
pr_run = _latest_ci_run_for_branch(branch)
|
||||
|
||||
if pr_run and pr_run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
|
||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
|
||||
prompt = (
|
||||
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
|
||||
f"(PR #{pr_number}). "
|
||||
f"CI run: {_ci_run_url(pr_run['id'])}. "
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||
"Do NOT reference any issue numbers in commit messages "
|
||||
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
|
||||
"issue via a commit message would be a bug. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"When done, stop."
|
||||
)
|
||||
session_name = f"ci-fix-pr-{pr_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
|
||||
return 0
|
||||
|
||||
if not pr_run:
|
||||
# No CI run yet — might be that CI hasn't triggered yet.
|
||||
# Wait up to 15 min before giving up.
|
||||
pr_created_at = pr.get("created_at", "")
|
||||
try:
|
||||
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
|
||||
age_s = (datetime.now(timezone.utc) - created).total_seconds()
|
||||
except Exception:
|
||||
age_s = 999999
|
||||
if age_s < 900:
|
||||
print(
|
||||
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
|
||||
)
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
print(
|
||||
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
|
||||
"agent may not have pushed. Setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
|
||||
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
return 0
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
|
||||
)
|
||||
return 0
|
||||
if _find_pr_for_branch(branch):
|
||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
|
||||
if merge_result == "rebase-spawned":
|
||||
return 0
|
||||
if merge_result == "merged":
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
|
||||
"merge command). Please merge manually.",
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# No open PR — check if it was already merged.
|
||||
merged_pr = _find_pr_for_branch(branch, state="closed")
|
||||
if merged_pr and merged_pr.get("merged"):
|
||||
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
|
||||
_close_issue(pending_issue)
|
||||
return 0
|
||||
|
||||
# No open or merged PR — the agent may not have created one, or it was
|
||||
# closed without merging (the bug this block was added to catch).
|
||||
print(
|
||||
f"No open or merged PR found for branch {branch!r} "
|
||||
f"(issue #{pending_issue}) — setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
return 0
|
||||
|
||||
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
|
||||
# This handles PRs whose CI has passed but were never merged because the
|
||||
# state file was cleared (loop restart, killed agent, manual intervention).
|
||||
open_prs = _open_issue_prs()
|
||||
for pr in open_prs:
|
||||
pr_number = pr["number"]
|
||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||
head = pr.get("head", {})
|
||||
branch = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
m = re.match(r"^issue-(\d+)-fix$", branch or "")
|
||||
issue_num = int(m.group(1)) if m else None
|
||||
pr_run = _latest_ci_run_for_pr(pr_number)
|
||||
|
||||
if pr_run and pr_run.get("status") == "running":
|
||||
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
|
||||
_write_state(None, issue_num, "pending-ci")
|
||||
return 0
|
||||
|
||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
|
||||
continue
|
||||
|
||||
if pr_run and pr_run.get("status") == "success":
|
||||
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
|
||||
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
|
||||
continue
|
||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
|
||||
continue
|
||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||
# (e.g. branch-protection rules not satisfied).
|
||||
if _find_pr_for_branch(branch):
|
||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
|
||||
if merge_result == "rebase-spawned":
|
||||
return 0
|
||||
if merge_result == "merged":
|
||||
if issue_num:
|
||||
_close_issue(issue_num)
|
||||
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
|
||||
else:
|
||||
print(f"Catch-up: merged PR #{pr_number} after retry.")
|
||||
return 0
|
||||
print(
|
||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||
"— skipping to avoid infinite retry."
|
||||
)
|
||||
if issue_num:
|
||||
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
issue_num,
|
||||
f"Automatic merge of PR #{pr_number} failed (PR is still open "
|
||||
"after the merge command). Please merge manually.",
|
||||
)
|
||||
continue
|
||||
if issue_num:
|
||||
_close_issue(issue_num)
|
||||
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
|
||||
else:
|
||||
print(f"Merged PR #{pr_number}.")
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (main branch only) ────────────────────────────────
|
||||
run = _latest_main_ci_run()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
if pending_issue:
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
|
||||
if run and run.get("status") in ("failure", "error"):
|
||||
# Guard: if the same main CI run has been failing since the last ci-fix
|
||||
# agent started, that agent pushed to a branch instead of main. Before
|
||||
# spawning another agent, check whether any CI run is currently in
|
||||
# progress (the branch run) and wait if so.
|
||||
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||
in_flight = [
|
||||
r for r in (check or {}).get("workflow_runs", [])
|
||||
if r.get("status") == "running"
|
||||
]
|
||||
if in_flight:
|
||||
print(
|
||||
f"Main CI still shows the same failed run {run['id']}; "
|
||||
f"{_ci_run_url(in_flight[0]['id'])} is running "
|
||||
"(previous ci-fix pushed to a branch). Waiting."
|
||||
)
|
||||
return 0
|
||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||
prompt = (
|
||||
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
|
||||
f"The CI run ID is {run['id']}. "
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push directly to main. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"Do NOT reference any issue numbers in commit messages "
|
||||
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
|
||||
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
|
||||
"Do NOT close any issues. "
|
||||
"When done, stop."
|
||||
)
|
||||
pid = _start_agent(prompt, "ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
|
||||
ci_run_id=run["id"] if run else None)
|
||||
return 0
|
||||
|
||||
# CI is ok (or no run).
|
||||
if pending_issue:
|
||||
latest_run_id = run["id"] if run else None
|
||||
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
|
||||
# CI run hasn't changed since the agent was launched → agent pushed nothing
|
||||
# (likely crashed or hit a rate limit).
|
||||
print(
|
||||
f"No new CI run since agent started for {_issue_url(pending_issue)} "
|
||||
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
"The agent exited without pushing any changes (no new CI run was triggered). "
|
||||
"This usually means the agent hit a rate limit or crashed at startup. "
|
||||
"The issue has been set to State/Question — please review the agent log and retry.",
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
|
||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# Find a ToPlan issue — planning takes priority over implementation.
|
||||
to_plan = _to_plan_issues()
|
||||
if to_plan:
|
||||
issue = to_plan[0]
|
||||
issue_number = issue["number"]
|
||||
issue_title = issue["title"]
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
print(f"Starting planning agent for {_issue_url(issue_number)} {issue_title}")
|
||||
_set_labels(issue_number, add=[LABEL_IN_PROGRESS], remove=[LABEL_TO_PLAN])
|
||||
|
||||
plan_prompt = f"""Analyze Codeberg issue #{issue_number} in the guettli/sharedinbox repository and write a detailed implementation plan.
|
||||
|
||||
Issue title: {issue_title}
|
||||
|
||||
Issue body:
|
||||
{issue_body}
|
||||
|
||||
Instructions:
|
||||
- Read and understand the issue thoroughly.
|
||||
- Explore the relevant parts of the codebase to understand the current structure.
|
||||
- Write a detailed implementation plan as a comment on the issue using:
|
||||
fgj issue comment {issue_number} --repo {REPO} --body "..."
|
||||
The plan should cover: which files to change, what approach to take, and any risks or open questions.
|
||||
- Do NOT write any code, do NOT create any branches or PRs, do NOT modify any files.
|
||||
- If the issue is unclear or you need more information, set the label to State/Question
|
||||
and stop (do NOT close the issue).
|
||||
- When you have posted the plan as an issue comment, stop.
|
||||
"""
|
||||
session_name = f"plan-issue-{issue_number}"
|
||||
pid = _start_agent(plan_prompt, session_name)
|
||||
_write_state(pid, issue_number, "plan", issue_title, session_name=session_name)
|
||||
return 0
|
||||
|
||||
# Find a Ready issue.
|
||||
issues = _ready_issues()
|
||||
if not issues:
|
||||
print("No issues with State/ToPlan or State/Ready. Nothing to do.")
|
||||
return 0
|
||||
|
||||
issue = issues[0]
|
||||
issue_number = issue["number"]
|
||||
issue_title = issue["title"]
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
||||
|
||||
# Mark InProgress before starting so the next cron tick sees it even if
|
||||
# the agent hasn't had time to do so yet.
|
||||
_set_labels(
|
||||
issue_number,
|
||||
add=[LABEL_IN_PROGRESS],
|
||||
remove=[LABEL_READY],
|
||||
)
|
||||
|
||||
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
|
||||
|
||||
Issue title: {issue_title}
|
||||
|
||||
Issue body:
|
||||
{issue_body}
|
||||
|
||||
Instructions:
|
||||
- Understand the issue thoroughly before writing any code.
|
||||
- Implement the required change, following the existing code style.
|
||||
- Write or update tests as appropriate.
|
||||
- Run 'task check' locally and fix any failures before committing.
|
||||
- Commit with a descriptive message and include (#{issue_number}) in the title,
|
||||
e.g. "feat: description (#{issue_number})".
|
||||
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
|
||||
after CI passes; using those keywords would close it prematurely or wrongly.
|
||||
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||
git checkout -b issue-{issue_number}-fix
|
||||
git push -u origin issue-{issue_number}-fix
|
||||
fgj pr create --title "fix: <short description> (#{issue_number})" \\
|
||||
--head issue-{issue_number}-fix --base main --repo {REPO}
|
||||
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR — the loop handles that after CI passes.
|
||||
- If you hit a blocker you cannot resolve, set the issue label to State/Question
|
||||
and stop (do NOT close the issue).
|
||||
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
|
||||
"""
|
||||
|
||||
session_name = f"issue-{issue_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
current_run_id = run["id"] if run else None
|
||||
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
sub.add_parser("list", help="List recent agent sessions")
|
||||
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "list":
|
||||
return cmd_list()
|
||||
if args.cmd == "monitor":
|
||||
return cmd_monitor()
|
||||
return _run_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -11,15 +11,12 @@ 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',
|
||||
'lib/core/repositories/mailbox_repository.dart',
|
||||
'lib/core/repositories/share_key_repository.dart',
|
||||
'lib/core/repositories/sync_log_repository.dart',
|
||||
'lib/core/repositories/undo_repository.dart',
|
||||
'lib/core/repositories/search_history_repository.dart',
|
||||
'lib/core/models/undo_action.dart',
|
||||
'lib/core/storage/secure_storage.dart',
|
||||
};
|
||||
@@ -35,8 +32,6 @@ const _excluded = {
|
||||
'lib/main.dart',
|
||||
'lib/ui/router.dart',
|
||||
'lib/ui/screens/account_list_screen.dart',
|
||||
'lib/ui/screens/account_receive_screen.dart',
|
||||
'lib/ui/screens/account_send_screen.dart',
|
||||
'lib/ui/screens/add_account_screen.dart',
|
||||
'lib/ui/screens/address_emails_screen.dart',
|
||||
'lib/ui/screens/changelog_screen.dart',
|
||||
@@ -53,13 +48,9 @@ const _excluded = {
|
||||
'lib/ui/screens/thread_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_screen.dart',
|
||||
'lib/ui/widgets/folder_drawer.dart',
|
||||
'lib/ui/widgets/secure_email_webview.dart',
|
||||
'lib/ui/widgets/snooze_picker.dart',
|
||||
'lib/ui/widgets/try_connection_button.dart',
|
||||
'lib/ui/widgets/undo_shell.dart',
|
||||
'lib/ui/screens/about_screen.dart',
|
||||
'lib/ui/utils/about_markdown.dart',
|
||||
'lib/ui/widgets/email_tile.dart',
|
||||
'lib/core/sync/account_sync_manager.dart',
|
||||
'lib/core/sync/background_sync.dart',
|
||||
'lib/core/sync/reliability_runner.dart',
|
||||
@@ -68,11 +59,8 @@ const _excluded = {
|
||||
'lib/data/repositories/account_repository_impl.dart',
|
||||
'lib/data/repositories/email_repository_impl.dart',
|
||||
'lib/data/repositories/mailbox_repository_impl.dart',
|
||||
'lib/data/repositories/share_key_repository_impl.dart',
|
||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||
'lib/data/repositories/undo_repository_impl.dart',
|
||||
'lib/data/repositories/search_history_repository_impl.dart',
|
||||
'lib/core/services/update_service.dart',
|
||||
};
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that all *.mocks.dart files are up to date.
|
||||
# Re-runs build_runner and fails if any generated mock differs from what is committed.
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "check-mocks: regenerating..."
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if fvm flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1; then
|
||||
grep -vE '^\[' "$tmp" || true
|
||||
else
|
||||
cat "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANGED=$(git diff --name-only -- '*.mocks.dart')
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "ERROR: The following mock files are out of date:"
|
||||
echo "$CHANGED"
|
||||
echo "Run 'task codegen' and commit the regenerated mocks."
|
||||
exit 1
|
||||
fi
|
||||
echo "check-mocks: all mock files are up to date."
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fail if binary files (other than images and fonts) are staged for commit.
|
||||
# Prevents accidental inclusion of build artifacts, databases, compiled binaries.
|
||||
set -euo pipefail
|
||||
|
||||
ALLOWED_EXTENSIONS='(png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2)'
|
||||
|
||||
# git diff --numstat shows "- - path" for binary files
|
||||
BINARY=$(git diff --cached --numstat | awk '$1=="-" && $2=="-" {print $3}')
|
||||
|
||||
if [ -z "$BINARY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BLOCKED=''
|
||||
while IFS= read -r f; do
|
||||
if ! echo "$f" | grep -qiE "\.$ALLOWED_EXTENSIONS$"; then
|
||||
BLOCKED="$BLOCKED\n $f"
|
||||
fi
|
||||
done <<< "$BINARY"
|
||||
|
||||
if [ -n "$BLOCKED" ]; then
|
||||
echo "Binary files staged for commit (not allowed):"
|
||||
echo -e "$BLOCKED"
|
||||
echo ""
|
||||
echo "If this is intentional, add the extension to ALLOWED_EXTENSIONS in scripts/check_no_binary.sh"
|
||||
exit 1
|
||||
fi
|
||||
+37
-55
@@ -6,49 +6,24 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
import google_auth_httplib2
|
||||
import httplib2
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
|
||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||
TRACK = "internal"
|
||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||
_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_service(creds):
|
||||
authorized_http = google_auth_httplib2.AuthorizedHttp(
|
||||
creds, http=httplib2.Http(timeout=_TIMEOUT)
|
||||
)
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
# 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()
|
||||
return build("androidpublisher", "v3", http=authorized_http)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -65,47 +40,54 @@ def main():
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
)
|
||||
session = AuthorizedSession(creds)
|
||||
|
||||
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||
edit_resp.raise_for_status()
|
||||
edit_id = edit_resp.json()["id"]
|
||||
service = _make_service(creds)
|
||||
|
||||
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
|
||||
edit_id = edit["id"]
|
||||
|
||||
# The resumable upload can fail with RedirectMissingLocation on transient
|
||||
# network hiccups. Retry the upload (with a fresh MediaFileUpload each
|
||||
# time) using exponential backoff before giving up.
|
||||
version_code = None
|
||||
last_exc = None
|
||||
bundle = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
|
||||
media = MediaFileUpload(
|
||||
AAB_PATH, mimetype="application/octet-stream", resumable=True
|
||||
)
|
||||
bundle = (
|
||||
service.edits()
|
||||
.bundles()
|
||||
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
||||
.execute(num_retries=3)
|
||||
)
|
||||
version_code = bundle["versionCode"]
|
||||
break
|
||||
except Exception as exc:
|
||||
except httplib2.error.RedirectMissingLocation as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(
|
||||
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
|
||||
f"Upload attempt {attempt + 1} failed (redirect error), "
|
||||
f"retrying in {delay}s…"
|
||||
)
|
||||
time.sleep(delay)
|
||||
if bundle is None:
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
|
||||
version_code = bundle["versionCode"]
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
track_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()
|
||||
service.edits().tracks().update(
|
||||
packageName=PACKAGE_NAME,
|
||||
editId=edit_id,
|
||||
track=TRACK,
|
||||
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
).execute(num_retries=3)
|
||||
|
||||
commit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||
timeout=30,
|
||||
)
|
||||
commit_resp.raise_for_status()
|
||||
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute(num_retries=3)
|
||||
print(f"Deployed version {version_code} to {TRACK} track")
|
||||
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Hugo markdown pages listing builds fetched from the server.
|
||||
|
||||
Reads build artifacts under public_html/builds/ on the deployment server via SSH,
|
||||
parses the git hash from each filename, fetches the commit title from the
|
||||
Codeberg API, then writes Hugo content pages to website/content/builds/.
|
||||
|
||||
Covers two platforms:
|
||||
- Linux: sharedinbox-linux-amd64-<hash>.tar.gz
|
||||
- Android: sharedinbox-mua-<hash>.apk
|
||||
|
||||
At most MAX_BUILDS_PER_PLATFORM of the most-recent builds are shown per platform.
|
||||
|
||||
These generated pages are not tracked in git (see .gitignore).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
CODEBERG_REPO = "guettli/sharedinbox"
|
||||
REMOTE_BUILDS_DIR = "public_html/builds"
|
||||
CONTENT_DIR = Path("website/content/builds")
|
||||
BASE_URL = "https://sharedinbox.de"
|
||||
CODEBERG_BASE = "https://codeberg.org"
|
||||
MAX_BUILDS_PER_PLATFORM = 30
|
||||
|
||||
|
||||
def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"WARNING: ssh exit {result.returncode} listing {pattern} on {ssh_user}@{ssh_host}"
|
||||
" — build history will be empty for this pattern",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return []
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def get_commit_info(hash_val: str) -> tuple[str, str]:
|
||||
"""Return (title, datetime_iso) for the given commit hash."""
|
||||
url = f"https://codeberg.org/api/v1/repos/{CODEBERG_REPO}/git/commits/{hash_val}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "sharedinbox-ci"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
title = data.get("commit", {}).get("message", "").split("\n")[0]
|
||||
dt = data.get("commit", {}).get("committer", {}).get("date", "")
|
||||
return title, dt
|
||||
except Exception as exc:
|
||||
print(f" warning: could not fetch commit info for {hash_val}: {exc}", file=sys.stderr)
|
||||
return hash_val, ""
|
||||
|
||||
|
||||
def parse_builds(
|
||||
paths: list[str],
|
||||
path_re: re.Pattern, # type: ignore[type-arg]
|
||||
) -> dict[str, list[tuple[str, str, str, str]]]:
|
||||
"""Parse build file paths into {date_key: [(hash, url, title, dt), ...]}."""
|
||||
limited = paths[-MAX_BUILDS_PER_PLATFORM:] if len(paths) > MAX_BUILDS_PER_PLATFORM else paths
|
||||
days: dict[str, list[tuple[str, str, str, str]]] = {}
|
||||
for path in limited:
|
||||
m = path_re.match(path)
|
||||
if not m:
|
||||
print(f" skipping unexpected path: {path}", file=sys.stderr)
|
||||
continue
|
||||
year, month, day, filename, hash_val = m.groups()
|
||||
date_key = f"{year}/{month}/{day}"
|
||||
download_url = f"{BASE_URL}/builds/{year}/{month}/{day}/{filename}"
|
||||
commit_title, commit_dt = get_commit_info(hash_val)
|
||||
days.setdefault(date_key, []).append((hash_val, download_url, commit_title, commit_dt))
|
||||
return days
|
||||
|
||||
|
||||
def render_entries(
|
||||
entries: list[tuple[str, str, str, str]],
|
||||
link_label: str,
|
||||
) -> str:
|
||||
lines = []
|
||||
for hash_val, download_url, commit_title, commit_dt in entries:
|
||||
commit_url = f"{CODEBERG_BASE}/{CODEBERG_REPO}/commit/{hash_val}"
|
||||
dt_str = f" · {commit_dt}" if commit_dt else ""
|
||||
lines.append(
|
||||
f"- [{commit_title}]({commit_url}){dt_str} \n"
|
||||
f" [{link_label}]({download_url}) (`{hash_val}`)\n"
|
||||
)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ssh_user = os.environ.get("SSH_USER", "")
|
||||
ssh_host = os.environ.get("SSH_HOST", "")
|
||||
if not ssh_user or not ssh_host:
|
||||
print("SSH_USER and SSH_HOST must be set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Listing Linux builds on {ssh_host}…")
|
||||
linux_paths = list_remote_files(ssh_user, ssh_host, "sharedinbox-linux-amd64-*.tar.gz")
|
||||
print(f"Found {len(linux_paths)} Linux build(s)")
|
||||
linux_re = re.compile(
|
||||
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-linux-amd64-(.+)\.tar\.gz)$"
|
||||
)
|
||||
linux_days = parse_builds(linux_paths, linux_re)
|
||||
|
||||
print(f"Listing Android APKs on {ssh_host}…")
|
||||
apk_paths = list_remote_files(ssh_user, ssh_host, "*.apk")
|
||||
print(f"Found {len(apk_paths)} APK(s)")
|
||||
apk_re = re.compile(
|
||||
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-mua-(.+)\.apk)$"
|
||||
)
|
||||
android_days = parse_builds(apk_paths, apk_re)
|
||||
|
||||
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# _index.md: platform sections, newest-first within each
|
||||
index_lines = ["---\ntitle: Builds\n---\n\n"]
|
||||
|
||||
index_lines.append(f"## Linux (last {MAX_BUILDS_PER_PLATFORM})\n\n")
|
||||
if linux_days:
|
||||
for date_key in sorted(linux_days, reverse=True):
|
||||
year, month, day = date_key.split("/")
|
||||
index_lines.append(f"### {year}-{month}-{day}\n\n")
|
||||
index_lines.append(render_entries(linux_days[date_key], "Download"))
|
||||
index_lines.append("\n")
|
||||
else:
|
||||
index_lines.append("_No Linux builds yet._\n\n")
|
||||
|
||||
index_lines.append(f"## Android (last {MAX_BUILDS_PER_PLATFORM})\n\n")
|
||||
if android_days:
|
||||
for date_key in sorted(android_days, reverse=True):
|
||||
year, month, day = date_key.split("/")
|
||||
index_lines.append(f"### {year}-{month}-{day}\n\n")
|
||||
index_lines.append(render_entries(android_days[date_key], "Download APK"))
|
||||
index_lines.append("\n")
|
||||
else:
|
||||
index_lines.append("_No Android builds yet._\n\n")
|
||||
|
||||
(CONTENT_DIR / "_index.md").write_text("".join(index_lines), encoding="utf-8")
|
||||
|
||||
# Per-day pages (combined)
|
||||
all_days = set(linux_days) | set(android_days)
|
||||
for date_key in sorted(all_days):
|
||||
year, month, day = date_key.split("/")
|
||||
date_iso = f"{year}-{month}-{day}"
|
||||
day_dir = CONTENT_DIR / year / month
|
||||
day_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lines = [f"---\ntitle: 'Builds for {date_iso}'\ndate: {date_iso}T00:00:00Z\n---\n\n"]
|
||||
if date_key in linux_days:
|
||||
lines.append("## Linux\n\n")
|
||||
lines.append(render_entries(linux_days[date_key], "Download"))
|
||||
lines.append("\n")
|
||||
if date_key in android_days:
|
||||
lines.append("## Android\n\n")
|
||||
lines.append(render_entries(android_days[date_key], "Download APK"))
|
||||
lines.append("\n")
|
||||
(day_dir / f"{day}.md").write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
total_linux = sum(len(v) for v in linux_days.values())
|
||||
total_android = sum(len(v) for v in android_days.values())
|
||||
print(
|
||||
f"Generated pages: {total_linux} Linux build(s), {total_android} Android build(s) "
|
||||
f"across {len(all_days)} day(s)"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the Firebase Test Lab Dagger pipeline with Gradle/Dagger noise filtered out.
|
||||
# Retries up to 3 times on transient Dagger engine connectivity errors.
|
||||
set -uo pipefail
|
||||
|
||||
OUT=$(mktemp)
|
||||
RC_FILE=$(mktemp)
|
||||
trap 'rm -f "$OUT" "$RC_FILE"' EXIT
|
||||
|
||||
_strip_ansi() {
|
||||
sed 's/\x1b\[[0-9;]*[mGKHFJ]//g'
|
||||
}
|
||||
|
||||
_filter_noise() {
|
||||
grep -vE \
|
||||
'> Task :.+(UP-TO-DATE|NO-SOURCE|SKIPPED)'\
|
||||
'|[0-9]+ files found for path '\''lib/'\
|
||||
'|^Inputs:'\
|
||||
'|^[[:space:]]+-[[:space:]]/'\
|
||||
'|\[Incubating\]'\
|
||||
'|Deprecated Gradle features'\
|
||||
'|warning-mode all'\
|
||||
'|please refer to https://docs\.gradle'\
|
||||
'|[0-9]+ actionable tasks'\
|
||||
'|^warning: \[options\]'\
|
||||
'|^Note: Some input files'\
|
||||
'|Starting a Gradle Daemon'\
|
||||
'|Have questions, feedback, or issues'\
|
||||
'|https://firebase\.google\.com/support'\
|
||||
'|^\s*[┆│]\s*$' \
|
||||
|| true
|
||||
}
|
||||
|
||||
_run() {
|
||||
: > "$OUT" ; : > "$RC_FILE"
|
||||
{
|
||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||
--project-id "$FIREBASE_PROJECT_ID"
|
||||
echo $? > "$RC_FILE"
|
||||
} 2>&1 | tee "$OUT" | _strip_ansi | _filter_noise
|
||||
}
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
_run && break
|
||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
||||
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||
else
|
||||
exit "$RC"
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$(cat "$RC_FILE" 2>/dev/null || echo 0)"
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
MAX_PROBE_ATTEMPTS=5
|
||||
PROBE_DELAY=30
|
||||
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||
echo "Found active server on $host:$port"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||
exit 0
|
||||
fi
|
||||
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
||||
sleep $PROBE_DELAY
|
||||
done
|
||||
|
||||
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
timeout 8 dagger version >/dev/null 2>&1; then
|
||||
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||
|
||||
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||
mkdir -p /tmp/dagger-tls
|
||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||
chmod 600 /tmp/dagger-tls/client.key
|
||||
|
||||
# 3. Configure and start stunnel
|
||||
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||
cat << EOF > "$STUNNEL_CONF"
|
||||
client = yes
|
||||
foreground = yes
|
||||
pid = /tmp/stunnel.pid
|
||||
debug = warning
|
||||
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||
socket = r:SO_KEEPALIVE=1
|
||||
socket = r:TCP_KEEPIDLE=10
|
||||
socket = r:TCP_KEEPINTVL=5
|
||||
socket = r:TCP_KEEPCNT=3
|
||||
|
||||
[dagger]
|
||||
accept = 127.0.0.1:1774
|
||||
connect = $host:$port
|
||||
CAfile = /tmp/dagger-tls/ca.crt
|
||||
cert = /tmp/dagger-tls/client.crt
|
||||
key = /tmp/dagger-tls/client.key
|
||||
verifyChain = yes
|
||||
EOF
|
||||
|
||||
# Start stunnel in the background
|
||||
stunnel "$STUNNEL_CONF" &
|
||||
TUNNEL_PID=$!
|
||||
|
||||
# Give it a moment to establish
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||
echo "Error: stunnel failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Export environment for subsequent CI steps
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||
fi
|
||||
@@ -1,938 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for agent_loop.py."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import agent_loop
|
||||
|
||||
|
||||
class TestUrlHelpers(unittest.TestCase):
|
||||
def test_issue_url(self):
|
||||
url = agent_loop._issue_url(128)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
|
||||
|
||||
def test_ci_run_url(self):
|
||||
url = agent_loop._ci_run_url(4145144)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
|
||||
|
||||
|
||||
class TestStateFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
|
||||
self._tmp.close()
|
||||
self._orig = agent_loop.STATE_FILE
|
||||
agent_loop.STATE_FILE = Path(self._tmp.name)
|
||||
Path(self._tmp.name).unlink() # Start with no state file.
|
||||
|
||||
def tearDown(self):
|
||||
agent_loop.STATE_FILE = self._orig
|
||||
Path(self._tmp.name).unlink(missing_ok=True)
|
||||
|
||||
def test_write_state_stores_pid(self):
|
||||
agent_loop._write_state(12345, 91, "issue")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["pid"], 12345)
|
||||
self.assertNotIn("tmux_session", data)
|
||||
|
||||
def test_write_state_stores_issue_and_kind(self):
|
||||
agent_loop._write_state(99, 7, "ci-fix")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["issue"], 7)
|
||||
self.assertEqual(data["type"], "ci-fix")
|
||||
self.assertIn("started_at", data)
|
||||
|
||||
def test_read_state_returns_none_when_missing(self):
|
||||
self.assertIsNone(agent_loop._read_state())
|
||||
|
||||
def test_read_and_write_roundtrip(self):
|
||||
agent_loop._write_state(42, 10, "issue")
|
||||
state = agent_loop._read_state()
|
||||
self.assertIsNotNone(state)
|
||||
self.assertEqual(state["pid"], 42)
|
||||
self.assertEqual(state["issue"], 10)
|
||||
|
||||
def test_clear_state_removes_file(self):
|
||||
agent_loop._write_state(1, None, "ci-fix")
|
||||
agent_loop._clear_state()
|
||||
self.assertIsNone(agent_loop._read_state())
|
||||
|
||||
def test_write_state_stores_issue_title(self):
|
||||
agent_loop._write_state(42, 10, "issue", "My Test Issue")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["issue_title"], "My Test Issue")
|
||||
|
||||
def test_write_state_omits_issue_title_when_none(self):
|
||||
agent_loop._write_state(42, None, "ci-fix")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertNotIn("issue_title", data)
|
||||
|
||||
|
||||
class TestAgentAlive(unittest.TestCase):
|
||||
def test_own_pid_is_alive(self):
|
||||
self.assertTrue(agent_loop._agent_alive({"pid": os.getpid()}))
|
||||
|
||||
def test_nonexistent_pid_is_dead(self):
|
||||
self.assertFalse(agent_loop._agent_alive({"pid": 999999999}))
|
||||
|
||||
def test_missing_pid_returns_false(self):
|
||||
self.assertFalse(agent_loop._agent_alive({}))
|
||||
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||
|
||||
|
||||
class TestIsClaudeProcess(unittest.TestCase):
|
||||
def test_returns_true_for_claude_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_true_for_node_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_for_other_process(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_when_proc_missing(self):
|
||||
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
|
||||
class TestKillAgent(unittest.TestCase):
|
||||
def test_kill_sends_sigkill(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_called_once_with(1234, 9)
|
||||
|
||||
def test_kill_ignores_missing_process(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||
|
||||
def test_kill_noop_when_no_pid(self):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
def test_kill_skips_recycled_pid(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=False):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
|
||||
class TestStartAgent(unittest.TestCase):
|
||||
def _make_mock_proc(self, pid=42):
|
||||
proc = MagicMock()
|
||||
proc.pid = pid
|
||||
proc.stdin = io.BytesIO()
|
||||
return proc
|
||||
|
||||
def test_start_agent_returns_pid(self):
|
||||
mock_proc = self._make_mock_proc(pid=42)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc):
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
result = agent_loop._start_agent("do something", "issue-99")
|
||||
self.assertEqual(result, 42)
|
||||
|
||||
def test_start_agent_uses_popen_not_tmux(self):
|
||||
mock_proc = self._make_mock_proc(pid=7)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
with patch("agent_loop.subprocess.run") as mock_run:
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
agent_loop._start_agent("prompt", "ci-fix")
|
||||
mock_popen.assert_called_once()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_start_agent_passes_session_name_to_claude(self):
|
||||
mock_proc = self._make_mock_proc(pid=7)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
agent_loop._start_agent("prompt", "issue-55")
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
self.assertIn("issue-55", cmd)
|
||||
self.assertIn("claude", cmd[0])
|
||||
|
||||
def test_start_agent_uses_start_new_session(self):
|
||||
mock_proc = self._make_mock_proc(pid=7)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||
agent_loop._start_agent("prompt", "issue-55")
|
||||
kwargs = mock_popen.call_args[1]
|
||||
self.assertTrue(kwargs.get("start_new_session"))
|
||||
|
||||
|
||||
class TestMain(unittest.TestCase):
|
||||
"""Tests for the main() flow."""
|
||||
|
||||
def _make_mock_proc(self, pid=42):
|
||||
proc = MagicMock()
|
||||
proc.pid = pid
|
||||
proc.stdin = io.BytesIO()
|
||||
return proc
|
||||
|
||||
def _make_issue(self, number=10, title="Do something"):
|
||||
return {"number": number, "title": title, "body": "", "labels": []}
|
||||
|
||||
def test_sets_in_progress_before_starting_agent(self):
|
||||
"""_set_labels(InProgress) must be called before _start_agent."""
|
||||
call_order = []
|
||||
mock_proc = self._make_mock_proc(pid=55)
|
||||
|
||||
def fake_set_labels(issue, add, remove):
|
||||
call_order.append(("set_labels", add, remove))
|
||||
|
||||
def fake_start_agent(prompt, session_name):
|
||||
call_order.append(("start_agent", session_name))
|
||||
return 55
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
patch("agent_loop._write_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
labels_idx = next(
|
||||
i for i, c in enumerate(call_order) if c[0] == "set_labels"
|
||||
)
|
||||
agent_idx = next(
|
||||
i for i, c in enumerate(call_order) if c[0] == "start_agent"
|
||||
)
|
||||
self.assertLess(labels_idx, agent_idx,
|
||||
"_set_labels must be called before _start_agent")
|
||||
|
||||
def test_sets_in_progress_label_and_removes_ready(self):
|
||||
"""The InProgress label is added and the Ready label is removed."""
|
||||
captured = {}
|
||||
|
||||
def fake_set_labels(issue, add, remove):
|
||||
captured["add"] = add
|
||||
captured["remove"] = remove
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"):
|
||||
agent_loop._run_loop()
|
||||
|
||||
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
|
||||
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
|
||||
|
||||
def test_no_ready_issues_does_nothing(self):
|
||||
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._start_agent") as mock_start:
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_labels.assert_not_called()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
def test_prompt_does_not_tell_agent_to_close_issue(self):
|
||||
"""Agents must not close issues; the loop handles closing after CI passes."""
|
||||
captured_prompt = {}
|
||||
|
||||
def fake_start_agent(prompt, session_name):
|
||||
captured_prompt["prompt"] = prompt
|
||||
return 77
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
patch("agent_loop._write_state"):
|
||||
agent_loop._run_loop()
|
||||
|
||||
prompt = captured_prompt.get("prompt", "")
|
||||
# "do NOT close the issue" (blocker instruction) is fine; what must be
|
||||
# absent is any affirmative instruction to close on completion.
|
||||
self.assertNotIn("close the issue and stop", prompt.lower())
|
||||
|
||||
|
||||
class TestPendingCi(unittest.TestCase):
|
||||
"""Tests for the pending-CI state: issue closed only after CI passes."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999, # non-existent PID
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def _find_pr_open(self, branch, state="open"):
|
||||
if state == "open":
|
||||
return self._open_pr(branch)
|
||||
return None
|
||||
|
||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||
# First call: PR found open. Second call (post-merge verification): PR closed.
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(5)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_ci_passed_output_includes_ci_run_url(self):
|
||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._close_issue"), \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
||||
|
||||
def test_already_merged_pr_closes_issue_without_ci_url(self):
|
||||
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
|
||||
def find_pr(branch, state="open"):
|
||||
if state == "closed":
|
||||
return {"number": 5, "merged": True}
|
||||
return None
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
result = agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
self.assertIn("already merged", output)
|
||||
self.assertNotIn("/actions/runs/", output)
|
||||
|
||||
def test_no_pr_found_sets_question_label(self):
|
||||
"""When no open or merged PR exists for the pending branch, set State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
|
||||
|
||||
def test_does_not_close_issue_when_ci_fails(self):
|
||||
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state"), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
|
||||
def test_saves_pending_ci_state_while_ci_running(self):
|
||||
"""When CI is still running on PR branch after agent finishes, pending issue is preserved."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written["pid"] = pid
|
||||
written["issue"] = issue
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "running"}), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(written.get("issue"), 10)
|
||||
self.assertEqual(written.get("kind"), "pending-ci")
|
||||
self.assertIsNone(written.get("pid"))
|
||||
|
||||
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written["pid"] = pid
|
||||
written["issue"] = issue
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(written.get("issue"), 10)
|
||||
self.assertEqual(written.get("kind"), "ci-fix")
|
||||
|
||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(5)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_no_pending_issue_ci_fix_without_issue(self):
|
||||
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
|
||||
with patch("agent_loop._read_state", return_value={
|
||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": "ci-fix",
|
||||
}), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
|
||||
|
||||
class TestOutputFormat(unittest.TestCase):
|
||||
"""Verify output format: no [agent_loop] prefix, URLs in output."""
|
||||
|
||||
def test_output_starts_with_header(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
first_line = buf.getvalue().splitlines()[0]
|
||||
self.assertTrue(first_line.startswith("---------------------- Starting "),
|
||||
f"Unexpected first line: {first_line!r}")
|
||||
|
||||
def test_no_agent_loop_prefix_in_output(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertNotIn("[agent_loop]", buf.getvalue())
|
||||
|
||||
def test_ci_run_output_contains_url(self):
|
||||
run = {"id": 4145144, "status": "running"}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||
buf.getvalue())
|
||||
|
||||
def test_issue_output_contains_url_and_title(self):
|
||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
|
||||
self.assertIn("Fix something", output)
|
||||
|
||||
|
||||
class TestLatestMainCiRun(unittest.TestCase):
|
||||
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
|
||||
|
||||
def _ci_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def _deploy_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def test_skips_deploy_run_returns_ci_run(self):
|
||||
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
|
||||
# must be excluded by workflow_id filter.
|
||||
runs = [self._deploy_run(1), self._ci_run(2)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_returns_none_when_only_deploy_runs_exist(self):
|
||||
runs = [self._deploy_run(1)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": "success", "id": 1}]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_ci_push_to_main_run(self):
|
||||
runs = [self._ci_run(42, status="running")]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 42)
|
||||
|
||||
|
||||
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||
|
||||
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
|
||||
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
|
||||
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
|
||||
|
||||
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
|
||||
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
|
||||
|
||||
def _mock_tea_runs(self, runs):
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
|
||||
yield m
|
||||
|
||||
def test_pr_event_matches_via_event_payload(self):
|
||||
run = self._make_pr_run("issue-166-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 1)
|
||||
|
||||
def test_pr_event_does_not_match_wrong_branch(self):
|
||||
run = self._make_pr_run("issue-99-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_push_event_matches_via_prettyref(self):
|
||||
run = self._make_push_run("issue-166-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
|
||||
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
|
||||
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_head_branch_field_absent_still_works(self):
|
||||
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
|
||||
run = self._make_pr_run("issue-166-fix")
|
||||
self.assertNotIn("head_branch", run)
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
def test_returns_none_when_no_runs(self):
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_first_matching_run(self):
|
||||
runs = [
|
||||
self._make_pr_run("issue-166-fix", status="success"),
|
||||
self._make_pr_run("issue-166-fix", status="failure"),
|
||||
]
|
||||
runs[0]["id"] = 10
|
||||
runs[1]["id"] = 11
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertEqual(result["id"], 10)
|
||||
|
||||
|
||||
class TestFindSessionUuid(unittest.TestCase):
|
||||
"""Tests for _find_session_uuid()."""
|
||||
|
||||
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
|
||||
path = directory / filename
|
||||
with path.open("w") as fh:
|
||||
for entry in entries:
|
||||
fh.write(json.dumps(entry) + "\n")
|
||||
return path
|
||||
|
||||
def test_returns_uuid_for_matching_session_name(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-abc-123")
|
||||
|
||||
def test_returns_none_when_name_does_not_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_directory_missing(self):
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_no_agent_name_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "message", "content": "hello"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_scans_multiple_files_to_find_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "aaa.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
|
||||
])
|
||||
self._write_jsonl(projects_dir, "bbb.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-91")
|
||||
|
||||
|
||||
class TestRunLoopResumeCommand(unittest.TestCase):
|
||||
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
|
||||
|
||||
def _alive_state(self, session_name="issue-91"):
|
||||
return {
|
||||
"pid": os.getpid(), # own PID is always alive
|
||||
"issue": 91,
|
||||
"started_at": "2026-05-23T12:00:00+00:00",
|
||||
"type": "issue",
|
||||
"session_name": session_name,
|
||||
}
|
||||
|
||||
def test_resume_shows_uuid_when_found(self):
|
||||
buf = io.StringIO()
|
||||
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn(f"claude --resume {fake_uuid}", output)
|
||||
|
||||
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("scripts/agent_loop.py list", output)
|
||||
# Must NOT show the session name as a valid resume argument.
|
||||
self.assertNotIn("claude --resume issue-91", output)
|
||||
|
||||
def test_resume_not_shown_when_no_session_name(self):
|
||||
state = self._alive_state()
|
||||
del state["session_name"]
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=state), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertNotIn("Resume:", output)
|
||||
|
||||
|
||||
|
||||
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
||||
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
|
||||
|
||||
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
|
||||
return {"number": pr_number, "head": {"ref": branch}}
|
||||
|
||||
def test_skips_merge_when_issue_has_question_label(self):
|
||||
pr = self._make_pr()
|
||||
ci_run = {"id": 999, "status": "success"}
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
result = agent_loop._run_loop()
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_not_called()
|
||||
mock_comment.assert_not_called()
|
||||
mock_labels.assert_not_called()
|
||||
|
||||
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
|
||||
pr = self._make_pr()
|
||||
ci_run = {"id": 999, "status": "success"}
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||
patch("agent_loop._close_issue"):
|
||||
result = agent_loop._run_loop()
|
||||
self.assertEqual(result, 0)
|
||||
mock_merge.assert_called_once_with(50)
|
||||
|
||||
|
||||
class TestMergeFailsOpen(unittest.TestCase):
|
||||
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999,
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
|
||||
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
|
||||
written_state = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written_state["pid"] = pid
|
||||
written_state["issue"] = issue
|
||||
written_state["kind"] = kind
|
||||
written_state["session_name"] = session_name
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
|
||||
patch("agent_loop._start_agent", return_value=77) as mock_start, \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_start.assert_called_once()
|
||||
prompt = mock_start.call_args[0][0]
|
||||
self.assertIn("Rebase branch", prompt)
|
||||
self.assertIn("issue-10-fix", prompt)
|
||||
self.assertEqual(written_state.get("kind"), "pending-ci")
|
||||
self.assertEqual(written_state.get("issue"), 10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
|
||||
"""mergeable=true, second attempt succeeds → issue closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
|
||||
"""All retries exhausted with PR still open → falls through to State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(),
|
||||
self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
|
||||
|
||||
class TestHeartbeat(unittest.TestCase):
|
||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
|
||||
self._tmp.close()
|
||||
self._orig = agent_loop.HEARTBEAT_FILE
|
||||
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
|
||||
Path(self._tmp.name).unlink() # Start with no heartbeat file.
|
||||
|
||||
def tearDown(self):
|
||||
agent_loop.HEARTBEAT_FILE = self._orig
|
||||
Path(self._tmp.name).unlink(missing_ok=True)
|
||||
|
||||
def test_update_heartbeat_writes_timestamp(self):
|
||||
agent_loop._update_heartbeat()
|
||||
content = Path(self._tmp.name).read_text().strip()
|
||||
dt = datetime.fromisoformat(content)
|
||||
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||
self.assertLess(age, 5)
|
||||
|
||||
def test_update_heartbeat_creates_file(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
agent_loop._update_heartbeat()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
def test_monitor_healthy_when_recent(self):
|
||||
agent_loop._update_heartbeat()
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
def test_monitor_warns_when_heartbeat_missing(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_stale(self):
|
||||
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
||||
Path(self._tmp.name).write_text(stale)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_corrupted(self):
|
||||
Path(self._tmp.name).write_text("not-a-timestamp")
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_run_loop_updates_heartbeat(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
agent_loop._run_loop()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user