Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b9e0a8b0 | ||
|
|
3a08daa402 | ||
|
|
2336afa0d7 | ||
|
|
c343ed6bd7 | ||
|
|
1d5eb187bf |
@@ -6,6 +6,12 @@
|
|||||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
|
# Infrastructure tools required by CI workflows
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
stunnel4 \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|||||||
+100
-3
@@ -1,14 +1,111 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on: [push, pull_request]
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'test/**'
|
||||||
|
- 'integration_test/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '!assets/changelog.txt'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'analysis_options.yaml'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'stalwart-dev/**'
|
||||||
|
- 'ci/**'
|
||||||
|
- 'Taskfile.yml'
|
||||||
|
- 'drift_schemas/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'test/**'
|
||||||
|
- 'integration_test/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '!assets/changelog.txt'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'analysis_options.yaml'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'stalwart-dev/**'
|
||||||
|
- 'ci/**'
|
||||||
|
- 'Taskfile.yml'
|
||||||
|
- 'drift_schemas/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Dagger Remote Engine
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Locate Docker daemon for local Dagger engine
|
||||||
|
run: |
|
||||||
|
# Skip if remote Dagger engine is already configured (preferred path)
|
||||||
|
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
||||||
|
echo "Remote Dagger engine configured, no local Docker needed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try host Docker socket (DooD) if runner mounts it
|
||||||
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker available via host socket."
|
||||||
|
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
||||||
|
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
||||||
|
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
||||||
|
echo "CI will likely fail at the Dagger step." >&2
|
||||||
|
|
||||||
|
- name: Prune Dagger cache before check
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
||||||
|
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
||||||
|
run: |
|
||||||
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||||
|
|
||||||
- name: Run Full Check Suite
|
- name: Run Full Check Suite
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
run: task check-dagger
|
run: task check-dagger
|
||||||
|
|
||||||
|
- name: Prune Dagger cache after check
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: |
|
||||||
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|||||||
+135
-58
@@ -17,13 +17,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Detect Android and Linux changes
|
- name: Detect Android and Linux changes
|
||||||
id: diff
|
id: diff
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
run: |
|
||||||
# On workflow_dispatch always build everything
|
# On workflow_dispatch always build everything
|
||||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
@@ -32,54 +30,15 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||||
|
# when the parent is unavailable (initial commit, shallow clone).
|
||||||
# Skip if this exact commit was already successfully deployed (prevents
|
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||||
# hourly schedule from redeploying the same commit on every tick).
|
|| git show --name-only --format= HEAD)
|
||||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
|
||||||
import json, os, sys, urllib.request
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
||||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
data = json.loads(r.read())
|
|
||||||
runs = [
|
|
||||||
r for r in data.get("workflow_runs", [])
|
|
||||||
if r.get("status") == "success"
|
|
||||||
]
|
|
||||||
print(runs[0].get("commit_sha") or "")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"API check failed: {e}", file=sys.stderr)
|
|
||||||
print("")
|
|
||||||
PYEOF
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
|
||||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
|
||||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Diff from the last successfully deployed commit to catch all changes since
|
|
||||||
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
|
|
||||||
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
|
||||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
|
||||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
|
||||||
|| git show --name-only --format= HEAD)
|
|
||||||
else
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
|
||||||
|| git show --name-only --format= HEAD)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
echo "$CHANGED"
|
echo "$CHANGED"
|
||||||
|
|
||||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$android_re" \
|
echo "$CHANGED" | grep -qE "$android_re" \
|
||||||
@@ -90,6 +49,44 @@ jobs:
|
|||||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
test-android-firebase:
|
||||||
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||||
|
env:
|
||||||
|
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||||
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task test-android-firebase
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -100,23 +97,34 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Publish Android to Play Store
|
- name: Publish Android to Play Store
|
||||||
|
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||||
env:
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
@@ -128,23 +136,37 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy APK to server
|
- name: Build & Deploy APK to server
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-apk
|
run: task deploy-apk
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux Release
|
name: Build Linux Release
|
||||||
@@ -156,30 +178,85 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy Linux to server
|
- name: Build & Deploy Linux to server
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-linux
|
run: task deploy-linux
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
publish-website:
|
||||||
|
name: Publish Website Build History
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Generate build history and deploy website
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task publish-website
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
label-deploy-health:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [deploy-playstore, deploy-apk, build-linux]
|
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||||
if: |
|
if: |
|
||||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||||
|
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||||
@@ -192,7 +269,7 @@ jobs:
|
|||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||||
run: |
|
run: |
|
||||||
python3 - << 'PYEOF'
|
python3 - << 'PYEOF'
|
||||||
import os, json, urllib.request, urllib.error
|
import os, json, urllib.request, urllib.error
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
name: Firebase Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 3 * * *' # once per day at 3 AM
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-changes:
|
|
||||||
name: Detect Firebase-Relevant Changes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
outputs:
|
|
||||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Detect Firebase-relevant changes in last 24 hours
|
|
||||||
id: diff
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# On workflow_dispatch always run
|
|
||||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
|
||||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
|
|
||||||
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
|
|
||||||
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
|
|
||||||
| sort -u | grep -v '^$')
|
|
||||||
|
|
||||||
if [ -n "$CHANGED" ]; then
|
|
||||||
echo "Firebase-relevant files changed since $SINCE:"
|
|
||||||
echo "$CHANGED"
|
|
||||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
|
|
||||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
test-android-firebase:
|
|
||||||
name: Android Instrumented Tests (Firebase Test Lab)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
needs: [check-changes]
|
|
||||||
if: needs.check-changes.outputs.has_changes == 'true'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
|
||||||
env:
|
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
|
||||||
env:
|
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task test-android-firebase
|
|
||||||
|
|
||||||
- name: Create issue on test failure
|
|
||||||
if: failure()
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
python3 - << 'PYEOF'
|
|
||||||
import os, json, urllib.request, urllib.error
|
|
||||||
|
|
||||||
token = os.environ["FORGEJO_TOKEN"]
|
|
||||||
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
|
||||||
run_url = os.environ["RUN_URL"]
|
|
||||||
|
|
||||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
||||||
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
|
||||||
|
|
||||||
def api_get(path):
|
|
||||||
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
return json.loads(r.read())
|
|
||||||
|
|
||||||
def api_post(path, body):
|
|
||||||
data = json.dumps(body).encode()
|
|
||||||
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
return json.loads(r.read())
|
|
||||||
|
|
||||||
repo_labels = api_get("/labels")
|
|
||||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
|
||||||
|
|
||||||
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
|
||||||
|
|
||||||
title = "Firebase Tests failed — find root cause and fix"
|
|
||||||
body = (
|
|
||||||
"Firebase instrumented tests failed in the daily run.\n\n"
|
|
||||||
f"**Failed run:** {run_url}\n\n"
|
|
||||||
"## Steps to resolve\n\n"
|
|
||||||
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
|
|
||||||
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
|
|
||||||
"3. Close this issue once the root cause is resolved and the tests pass.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
issue = api_post("/issues", {
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
"labels": label_ids,
|
|
||||||
})
|
|
||||||
print(f"Created issue #{issue['number']}: {issue['html_url']}")
|
|
||||||
PYEOF
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Monitor Agent Loop
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */2 * * *' # every 2 hours
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
monitor:
|
||||||
|
name: Check Agent Loop Health
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check agent loop heartbeat
|
||||||
|
run: python3 scripts/agent_loop.py monitor
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Renovate
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
renovate:
|
|
||||||
name: Renovate
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
|
||||||
env:
|
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Run Renovate
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task renovate
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
name: Update Website
|
name: Deploy Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
|
||||||
- cron: '0 * * * *' # every hour on the hour
|
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
@@ -13,31 +11,22 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Build & Update Website
|
name: Build & Deploy Website
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Build & Deploy Website
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
- name: Build & Update Website
|
run: task website-deploy
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task publish-website
|
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Verify Website
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: scripts/website-verify.sh
|
||||||
|
|||||||
+1
-2
@@ -28,8 +28,7 @@ android/.gradle/
|
|||||||
android/local.properties
|
android/local.properties
|
||||||
android/app/google-services.json
|
android/app/google-services.json
|
||||||
android/key.properties
|
android/key.properties
|
||||||
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
android/app/src/main/java/io/flutter/plugins/
|
||||||
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
|
||||||
.android/
|
.android/
|
||||||
Android/
|
Android/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ repos:
|
|||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
name: check for direct dagger calls in workflows (use Task instead)
|
name: check for direct dagger calls in workflows (use Task instead)
|
||||||
language: system
|
language: system
|
||||||
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: dagger-progress-plain
|
- id: dagger-progress-plain
|
||||||
name: ensure all dagger calls use --progress=plain
|
name: ensure all dagger calls use --progress=plain
|
||||||
language: system
|
language: system
|
||||||
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
@@ -8,41 +8,32 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
|||||||
|
|
||||||
## Issue Label Workflow
|
## Issue Label Workflow
|
||||||
|
|
||||||
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
We use issues, follow this label state machine:
|
||||||
|
|
||||||
| Label | Trigger | Outcome |
|
- **State/Ready** — Issue is available to pick up
|
||||||
|---|---|---|
|
- **State/InProgress** — Set this when you start working on an issue
|
||||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
|
||||||
|
|
||||||
**State machine:**
|
List open issues ready to pick up:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||||
↘ NeedSupervisor (on failure)
|
|
||||||
|
|
||||||
loop/code → loop/code-in-progress → loop/code-done
|
|
||||||
↘ NeedSupervisor (on failure)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
Rules:
|
||||||
|
|
||||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
- Never start work on an issue without `State/Ready`
|
||||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
```bash
|
||||||
|
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||||
**Typical lifecycle for a new feature:**
|
```
|
||||||
|
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||||
```
|
- When done and CI is green, close the issue:
|
||||||
1. Create issue
|
```bash
|
||||||
2. Add label loop/plan → agent writes plan as comment
|
fgj issue close <NUMBER>
|
||||||
3. Review plan, request changes or approve
|
```
|
||||||
4. Add label loop/code → agent implements + opens PR
|
|
||||||
5. Review PR, merge
|
|
||||||
6. Close issue
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
|
|||||||
@@ -188,5 +188,3 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
|
|||||||
## Daily Workflow
|
## Daily Workflow
|
||||||
|
|
||||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||||
|
|
||||||
<!-- agentloop code test passed -->
|
|
||||||
|
|||||||
@@ -216,8 +216,3 @@ test/
|
|||||||
- **Settings** — list and remove accounts
|
- **Settings** — list and remove accounts
|
||||||
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
||||||
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
||||||
# CI Trigger
|
|
||||||
# CI Trigger 2
|
|
||||||
# Dummy commit to verify CI fixes
|
|
||||||
# Dummy commit 3
|
|
||||||
# CI Trigger 1780415300
|
|
||||||
|
|||||||
+5
-23
@@ -224,7 +224,7 @@ tasks:
|
|||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -238,7 +238,6 @@ tasks:
|
|||||||
|
|
||||||
publish-android:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
deps: [generate-changelog]
|
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
@@ -247,7 +246,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
@@ -294,11 +293,11 @@ tasks:
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
run_dagger "$@" && return 0
|
run_dagger "$@" && return 0
|
||||||
RC=$?
|
RC=$?
|
||||||
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||||
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||||
sleep 90
|
sleep 90
|
||||||
else
|
else
|
||||||
@@ -319,16 +318,7 @@ tasks:
|
|||||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
until [ -s "$PORTFILE" ]; do
|
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||||
sleep 0.05
|
|
||||||
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
|
||||||
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
|
||||||
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
|
||||||
RC=$?
|
|
||||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
|
||||||
exit $RC
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
PORT=$(cat "$PORTFILE")
|
PORT=$(cat "$PORTFILE")
|
||||||
retry_dagger env \
|
retry_dagger env \
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||||
@@ -345,14 +335,6 @@ tasks:
|
|||||||
- |
|
- |
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||||
|
|
||||||
renovate:
|
|
||||||
desc: Run Renovate bot against the repository via Dagger
|
|
||||||
preconditions:
|
|
||||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
|
||||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
|
||||||
cmds:
|
|
||||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ android {
|
|||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlinOptions {
|
||||||
compilerOptions {
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -69,7 +67,7 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
||||||
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
||||||
// references its class in all variants. Make it available for release compilation
|
// references its class in all variants. Make it available for release compilation
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package io.flutter.plugins;
|
|
||||||
|
|
||||||
import androidx.annotation.Keep;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import io.flutter.Log;
|
|
||||||
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generated file. Do not edit.
|
|
||||||
* This file is generated by the Flutter tool based on the
|
|
||||||
* plugins that support the Android platform.
|
|
||||||
*/
|
|
||||||
@Keep
|
|
||||||
public final class GeneratedPluginRegistrant {
|
|
||||||
private static final String TAG = "GeneratedPluginRegistrant";
|
|
||||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ require (
|
|||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
|
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
|
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
|
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
|
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
|
||||||
|
|||||||
+16
-85
@@ -181,7 +181,7 @@ func New(
|
|||||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||||
func (m *Ci) toolchain() *dagger.Container {
|
func (m *Ci) toolchain() *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.44.0").
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
@@ -195,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
|
|||||||
WithUser("ci").
|
WithUser("ci").
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
||||||
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||||
@@ -286,21 +285,6 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
|
||||||
// Gradle dependencies survive across Dagger execution-cache misses.
|
|
||||||
func (m *Ci) androidBase() *dagger.Container {
|
|
||||||
return m.setup(m.androidSrc()).
|
|
||||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
|
||||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
|
||||||
func (m *Ci) firebaseBase() *dagger.Container {
|
|
||||||
return m.setup(m.firebaseSrc()).
|
|
||||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
|
||||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
@@ -599,17 +583,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildLinuxRelease builds the Linux release bundle.
|
// BuildLinuxRelease builds the Linux release bundle.
|
||||||
func (m *Ci) BuildLinuxRelease(
|
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
|
||||||
// +optional
|
|
||||||
commitHash string,
|
|
||||||
) *dagger.Directory {
|
|
||||||
args := []string{"flutter", "build", "linux", "--release"}
|
|
||||||
if commitHash != "" {
|
|
||||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
|
||||||
}
|
|
||||||
return m.setup(m.linuxSrc()).
|
return m.setup(m.linuxSrc()).
|
||||||
WithExec(args).
|
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||||
Directory("build/linux/x64/release/bundle")
|
Directory("build/linux/x64/release/bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +598,7 @@ func (m *Ci) DeployLinux(
|
|||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
bundle := m.BuildLinuxRelease(commitHash)
|
bundle := m.BuildLinuxRelease()
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
@@ -638,27 +614,16 @@ func (m *Ci) DeployLinux(
|
|||||||
|
|
||||||
// setupKeystore decodes the base64 keystore into the android build container.
|
// setupKeystore decodes the base64 keystore into the android build container.
|
||||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||||
return m.androidBase().
|
return m.setup(m.androidSrc()).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
func (m *Ci) BuildAndroidApk(
|
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||||
keystoreBase64 *dagger.Secret,
|
|
||||||
keystorePassword *dagger.Secret,
|
|
||||||
buildNumber string,
|
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
|
||||||
// +optional
|
|
||||||
commitHash string,
|
|
||||||
) *dagger.File {
|
|
||||||
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
|
||||||
if commitHash != "" {
|
|
||||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
|
||||||
}
|
|
||||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||||
WithExec(args).
|
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,7 +639,7 @@ func (m *Ci) DeployApk(
|
|||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
buildNumber string,
|
buildNumber string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
@@ -690,7 +655,8 @@ func (m *Ci) DeployApk(
|
|||||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.firebaseBase().
|
built := m.setup(m.firebaseSrc()).
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
||||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||||
WithWorkdir("/src/android").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
@@ -749,17 +715,9 @@ func (m *Ci) TestAndroidFirebase(
|
|||||||
|
|
||||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||||
func (m *Ci) BuildAndroidRelease(
|
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
return m.setup(m.androidSrc()).
|
||||||
// +optional
|
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||||
commitHash string,
|
|
||||||
) *dagger.File {
|
|
||||||
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
|
||||||
if commitHash != "" {
|
|
||||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
|
||||||
}
|
|
||||||
return m.androidBase().
|
|
||||||
WithExec(args).
|
|
||||||
File("build/app/outputs/bundle/release/app-release.aab")
|
File("build/app/outputs/bundle/release/app-release.aab")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,41 +789,14 @@ func (m *Ci) PublishAndroid(
|
|||||||
playStoreConfig *dagger.Secret,
|
playStoreConfig *dagger.Secret,
|
||||||
keystoreBase64 *dagger.Secret,
|
keystoreBase64 *dagger.Secret,
|
||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
|
||||||
// +optional
|
|
||||||
commitHash string,
|
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
versionCode := int(time.Now().Unix())
|
versionCode := int(time.Now().Unix())
|
||||||
aab := m.BuildAndroidRelease(commitHash)
|
aab := m.BuildAndroidRelease()
|
||||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
|
||||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
|
||||||
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
|
|
||||||
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
|
|
||||||
// hardcoded 20/100 values before launching renovate.
|
|
||||||
const patchCmd = `for f in \
|
|
||||||
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
|
|
||||||
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
|
|
||||||
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
|
|
||||||
done`
|
|
||||||
return dag.Container().
|
|
||||||
From("renovate/renovate:43").
|
|
||||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
|
||||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
|
||||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
|
||||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
|
||||||
WithEnvVariable("LOG_LEVEL", "info").
|
|
||||||
WithUser("root").
|
|
||||||
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
|
||||||
WithUser("ubuntu").
|
|
||||||
WithExec([]string{"renovate"}).
|
|
||||||
Stdout(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||||
// or save it as a .md file to get a rendered diagram.
|
// or save it as a .md file to get a rendered diagram.
|
||||||
@@ -879,7 +810,7 @@ func (m *Ci) Graph() string {
|
|||||||
` + "```" + `mermaid
|
` + "```" + `mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||||
pubGet["pubGetLayer\nflutter pub get"]
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
|||||||
|
|
||||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
||||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -4,28 +4,6 @@ This file contains tasks which got implemented.
|
|||||||
|
|
||||||
Tasks get moved from next.md to done.md
|
Tasks get moved from next.md to done.md
|
||||||
|
|
||||||
## Tasks (2026-05-29)
|
|
||||||
|
|
||||||
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
|
|
||||||
all features from PR #307 (issue #299) were already merged into main via separate PRs:
|
|
||||||
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
|
|
||||||
- Configurable back button position for single mail view — merged via #299/#307 features in #300
|
|
||||||
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
|
|
||||||
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
|
|
||||||
- User preferences DB schema (v34–v36: `user_preferences` table) — in main
|
|
||||||
- PR #307 and issue #299 closed.
|
|
||||||
- Issue #315 closed.
|
|
||||||
|
|
||||||
## Tasks (2026-05-26)
|
|
||||||
|
|
||||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
|
||||||
dependencies up to date. All required components are in main:
|
|
||||||
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
|
||||||
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
|
||||||
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
|
||||||
- `Taskfile.yml` — `renovate` task
|
|
||||||
- Issue #257 closed.
|
|
||||||
|
|
||||||
## Tasks (2026-05-11)
|
## Tasks (2026-05-11)
|
||||||
|
|
||||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ void main() {
|
|||||||
|
|
||||||
// ── Check Sent folder ──────────────────────────────────────────────────
|
// ── Check Sent folder ──────────────────────────────────────────────────
|
||||||
// Use the drawer to switch folders (no back button on Linux desktop).
|
// Use the drawer to switch folders (no back button on Linux desktop).
|
||||||
await tester.tap(find.byTooltip('Open folders'));
|
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Sent'));
|
await tester.tap(find.text('Sent'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -331,7 +331,7 @@ void main() {
|
|||||||
expect(find.text(subject), findsOneWidget);
|
expect(find.text(subject), findsOneWidget);
|
||||||
|
|
||||||
// ── Check Inbox ────────────────────────────────────────────────────────
|
// ── Check Inbox ────────────────────────────────────────────────────────
|
||||||
await tester.tap(find.byTooltip('Open folders'));
|
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('INBOX'));
|
await tester.tap(find.text('INBOX'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
const int dbSchemaVersion = 36;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
enum MenuPosition { bottom, top }
|
|
||||||
|
|
||||||
enum AfterMailViewAction { nextMessage, showMailbox }
|
|
||||||
|
|
||||||
class UserPreferences {
|
|
||||||
const UserPreferences({
|
|
||||||
this.menuPosition = MenuPosition.bottom,
|
|
||||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
|
||||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
|
||||||
});
|
|
||||||
final MenuPosition menuPosition;
|
|
||||||
final MenuPosition mailViewButtonPosition;
|
|
||||||
final AfterMailViewAction afterMailViewAction;
|
|
||||||
}
|
|
||||||
@@ -11,13 +11,4 @@ abstract class MailboxRepository {
|
|||||||
|
|
||||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||||
Future<void> clearForResync(String accountId);
|
Future<void> clearForResync(String accountId);
|
||||||
|
|
||||||
/// Creates a new mailbox named [name] for [accountId] and tags it with
|
|
||||||
/// [role] in the local database. For JMAP accounts the role is also sent
|
|
||||||
/// to the server. Returns the newly created [Mailbox].
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ class SyncLogEntry {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.result,
|
required this.result,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.stackTrace,
|
|
||||||
this.isPermanent = false,
|
|
||||||
required this.protocol,
|
required this.protocol,
|
||||||
required this.emailsFetched,
|
required this.emailsFetched,
|
||||||
required this.emailsSkipped,
|
required this.emailsSkipped,
|
||||||
@@ -36,8 +34,6 @@ class SyncLogEntry {
|
|||||||
final int id;
|
final int id;
|
||||||
final String result; // 'ok' or 'error'
|
final String result; // 'ok' or 'error'
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? stackTrace;
|
|
||||||
final bool isPermanent;
|
|
||||||
final String protocol; // 'imap' or 'jmap'
|
final String protocol; // 'imap' or 'jmap'
|
||||||
final int emailsFetched;
|
final int emailsFetched;
|
||||||
final int emailsSkipped;
|
final int emailsSkipped;
|
||||||
@@ -58,8 +54,6 @@ abstract class SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? stackTrace,
|
|
||||||
bool isPermanent = false,
|
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -87,8 +81,6 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? stackTrace,
|
|
||||||
bool isPermanent = false,
|
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
|
|
||||||
abstract class UserPreferencesRepository {
|
|
||||||
Stream<UserPreferences> observePreferences();
|
|
||||||
Future<void> updateMenuPosition(MenuPosition position);
|
|
||||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
|
||||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
|
||||||
}
|
|
||||||
@@ -92,9 +92,8 @@ class ShareEncryptionService {
|
|||||||
) {
|
) {
|
||||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
try {
|
try {
|
||||||
final data = Uint8List.fromList(
|
final data =
|
||||||
base64.decode(s.substring(_pubKeyPrefix.length)),
|
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||||
);
|
|
||||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||||
return (
|
return (
|
||||||
keyId: data.sublist(0, _keyIdLen),
|
keyId: data.sublist(0, _keyIdLen),
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ class SieveInterpreter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _globMatch(String value, String pattern) {
|
bool _globMatch(String value, String pattern) {
|
||||||
final regexStr = RegExp.escape(
|
final regexStr =
|
||||||
pattern,
|
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
|
||||||
return RegExp('^$regexStr\$').hasMatch(value);
|
return RegExp('^$regexStr\$').hasMatch(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -466,7 +466,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readTaggedArg() {
|
String readTaggedArg() {
|
||||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
throw SieveParseException('Expected tagged argument at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected tagged argument at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? peekSizeUnit() {
|
String? peekSizeUnit() {
|
||||||
@@ -478,7 +480,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readDigits() {
|
String readDigits() {
|
||||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
throw SieveParseException('Expected number at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected number at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final start = _pos;
|
final start = _pos;
|
||||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
@@ -489,7 +493,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readQuotedString() {
|
String readQuotedString() {
|
||||||
if (_src[_pos] != '"') {
|
if (_src[_pos] != '"') {
|
||||||
throw SieveParseException('Expected " at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected " at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_pos++; // skip opening quote
|
_pos++; // skip opening quote
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
|
|||||||
@@ -260,8 +260,6 @@ class _AccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
stackTrace: st.toString(),
|
|
||||||
isPermanent: isPermanent,
|
|
||||||
protocol: 'imap',
|
protocol: 'imap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
@@ -515,8 +513,6 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
stackTrace: st.toString(),
|
|
||||||
isPermanent: isPermanent,
|
|
||||||
protocol: 'jmap',
|
protocol: 'jmap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
|
|||||||
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
||||||
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
||||||
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
||||||
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
.replaceAll(
|
||||||
|
"src='cid:${bareCid.toLowerCase()}'",
|
||||||
|
"src='$dataUri'",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:drift/native.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -193,9 +192,6 @@ class SyncLogs extends Table {
|
|||||||
DateTimeColumn get finishedAt => dateTime()();
|
DateTimeColumn get finishedAt => dateTime()();
|
||||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||||
TextColumn get protocolLog => text().nullable()();
|
TextColumn get protocolLog => text().nullable()();
|
||||||
// Added in schema v33: stack trace and permanent flag for error entries.
|
|
||||||
TextColumn get errorStackTrace => text().nullable()();
|
|
||||||
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-mailbox breakdown for a single sync cycle.
|
/// Per-mailbox breakdown for a single sync cycle.
|
||||||
@@ -307,23 +303,6 @@ class LocalSieveApplied extends Table {
|
|||||||
Set<Column> get primaryKey => {accountId, messageId};
|
Set<Column> get primaryKey => {accountId, messageId};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
|
||||||
@DataClassName('UserPreferencesRow')
|
|
||||||
class UserPreferences extends Table {
|
|
||||||
IntColumn get id => integer()();
|
|
||||||
// 'bottom' (default) | 'top'
|
|
||||||
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
|
|
||||||
// Added in schema v35: 'bottom' (default) | 'top'
|
|
||||||
TextColumn get mailViewButtonPosition =>
|
|
||||||
text().withDefault(const Constant('bottom'))();
|
|
||||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
|
||||||
TextColumn get afterMailViewAction =>
|
|
||||||
text().withDefault(const Constant('nextMessage'))();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<Column> get primaryKey => {id};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────────
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
@@ -344,14 +323,13 @@ class UserPreferences extends Table {
|
|||||||
LocalSieveScripts,
|
LocalSieveScripts,
|
||||||
LocalSieveApplied,
|
LocalSieveApplied,
|
||||||
ShareKeys,
|
ShareKeys,
|
||||||
UserPreferences,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => dbSchemaVersion;
|
int get schemaVersion => 32;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
@@ -592,25 +570,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 32) {
|
if (from < 32) {
|
||||||
await m.createTable(localSieveApplied);
|
await m.createTable(localSieveApplied);
|
||||||
}
|
}
|
||||||
if (from >= 7 && from < 33) {
|
|
||||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
|
||||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
|
||||||
}
|
|
||||||
if (from < 34) {
|
|
||||||
await m.createTable(userPreferences);
|
|
||||||
}
|
|
||||||
if (from >= 34 && from < 35) {
|
|
||||||
await m.addColumn(
|
|
||||||
userPreferences,
|
|
||||||
userPreferences.mailViewButtonPosition,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (from >= 34 && from < 36) {
|
|
||||||
await m.addColumn(
|
|
||||||
userPreferences,
|
|
||||||
userPreferences.afterMailViewAction,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ class LocalSieveRepository {
|
|||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
|
||||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||||
final rows = await (_db.select(
|
final rows = await (_db.select(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
.get();
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
@@ -27,9 +26,10 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||||
final rowId = int.parse(blobId);
|
final rowId = int.parse(blobId);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where(
|
||||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (row == null) throw Exception('Local script not found: $blobId');
|
if (row == null) throw Exception('Local script not found: $blobId');
|
||||||
return row.content;
|
return row.content;
|
||||||
@@ -44,7 +44,9 @@ class LocalSieveRepository {
|
|||||||
if (id != null) {
|
if (id != null) {
|
||||||
final rowId = int.parse(id);
|
final rowId = int.parse(id);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.write(
|
.write(
|
||||||
LocalSieveScriptsCompanion(
|
LocalSieveScriptsCompanion(
|
||||||
name: Value(name),
|
name: Value(name),
|
||||||
@@ -76,9 +78,10 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.delete(
|
await (_db.delete(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where(
|
||||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,9 @@ class LocalSieveRepository {
|
|||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import 'package:sharedinbox/data/db/database.dart';
|
|||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
DraftRepositoryImpl(
|
||||||
: _imapConnect = imapConnect;
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn? imapConnect,
|
||||||
|
}) : _imapConnect = imapConnect;
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -121,7 +124,10 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
Future<void> _syncWithServer(
|
||||||
|
imap.ImapClient client,
|
||||||
|
String accountId,
|
||||||
|
) async {
|
||||||
// Create/select the Drafts folder.
|
// Create/select the Drafts folder.
|
||||||
try {
|
try {
|
||||||
await client.createMailbox('Drafts');
|
await client.createMailbox('Drafts');
|
||||||
@@ -156,9 +162,8 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
? uidList.first.toString()
|
? uidList.first.toString()
|
||||||
: null;
|
: null;
|
||||||
if (uid != null) {
|
if (uid != null) {
|
||||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||||
DraftsCompanion(imapServerId: Value(uid)),
|
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (threadEmails.isEmpty) return;
|
|
||||||
final latest = threadEmails.last;
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
// Collect unique participants across the whole thread.
|
// Collect unique participants across the whole thread.
|
||||||
@@ -238,12 +237,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||||
final msg = fetch.messages.firstOrNull;
|
final msg = fetch.messages.first;
|
||||||
if (msg == null) {
|
|
||||||
throw StateError(
|
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final textBody = msg.decodeTextPlainPart();
|
final textBody = msg.decodeTextPlainPart();
|
||||||
final rawHtml = msg.decodeTextHtmlPart();
|
final rawHtml = msg.decodeTextHtmlPart();
|
||||||
final htmlBody =
|
final htmlBody =
|
||||||
@@ -331,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
'bodyProperties': [
|
||||||
|
'partId',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'size',
|
||||||
|
'subParts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -1949,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
final alreadyApplied = await (_db.select(
|
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||||
_db.localSieveApplied,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
.get();
|
||||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
@@ -2051,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (destMailbox == null) {
|
if (destMailbox == null) {
|
||||||
log(
|
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||||
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
destPath = destMailbox.path;
|
destPath = destMailbox.path;
|
||||||
@@ -2811,13 +2808,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
final fetch = await client.uidFetchMessage(
|
||||||
final msg = fetch.messages.firstOrNull;
|
emailRow.uid,
|
||||||
if (msg == null) {
|
'BODY.PEEK[]',
|
||||||
throw StateError(
|
);
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
final msg = fetch.messages.first;
|
||||||
);
|
|
||||||
}
|
|
||||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||||
final bytes = part.decodeContentBinary();
|
final bytes = part.decodeContentBinary();
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
@@ -2879,14 +2874,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
final fetch = await client.uidFetchMessage(
|
||||||
final msg = fetch.messages.firstOrNull;
|
emailRow.uid,
|
||||||
if (msg == null) {
|
'BODY.PEEK[]',
|
||||||
throw StateError(
|
);
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
return fetch.messages.first.renderMessage();
|
||||||
);
|
|
||||||
}
|
|
||||||
return msg.renderMessage();
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
}
|
}
|
||||||
@@ -3260,17 +3252,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.emails)
|
||||||
_db.emails,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(
|
await (_db.delete(_db.pendingChanges)
|
||||||
_db.pendingChanges,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(
|
await (_db.delete(_db.syncStates)
|
||||||
_db.syncStates,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -79,15 +79,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final mailboxes = await client.listMailboxes(recursive: true);
|
final mailboxes = await client.listMailboxes(recursive: true);
|
||||||
|
|
||||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
|
||||||
// folders the server doesn't tag with a special-use attribute.
|
|
||||||
final existingRows = await (_db.select(
|
|
||||||
_db.mailboxes,
|
|
||||||
)..where((t) => t.accountId.equals(account.id)))
|
|
||||||
.get();
|
|
||||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
|
||||||
|
|
||||||
for (final mb in mailboxes) {
|
for (final mb in mailboxes) {
|
||||||
final path = mb.path;
|
final path = mb.path;
|
||||||
final id = '${account.id}:$path';
|
final id = '${account.id}:$path';
|
||||||
@@ -105,12 +96,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
log('STATUS skipped for $path: $e');
|
log('STATUS skipped for $path: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the server-assigned role when available; fall back to the
|
|
||||||
// existing DB role so that manually-created folders (e.g. a user
|
|
||||||
// who just created their Archive folder) keep their role across syncs
|
|
||||||
// when the IMAP server does not expose a special-use attribute.
|
|
||||||
final role = _imapRole(mb) ?? existingRoles[id];
|
|
||||||
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -119,7 +104,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
name: mb.name,
|
name: mb.name,
|
||||||
unreadCount: Value(unread),
|
unreadCount: Value(unread),
|
||||||
totalCount: Value(total),
|
totalCount: Value(total),
|
||||||
role: Value(role),
|
role: Value(_imapRole(mb)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -321,112 +306,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {
|
Future<void> clearForResync(String accountId) async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<model.Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
|
||||||
final password = await _accounts.getPassword(accountId);
|
|
||||||
switch (account.type) {
|
|
||||||
case account_model.AccountType.imap:
|
|
||||||
return _createMailboxWithRoleImap(account, password, name, role);
|
|
||||||
case account_model.AccountType.jmap:
|
|
||||||
return _createMailboxWithRoleJmap(account, password, name, role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<model.Mailbox> _createMailboxWithRoleImap(
|
|
||||||
account_model.Account account,
|
|
||||||
String password,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final client = await _imapConnect(
|
|
||||||
account,
|
|
||||||
_effectiveUsername(account),
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await client.createMailbox(name);
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
|
||||||
final id = '${account.id}:$name';
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
|
||||||
MailboxesCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
accountId: account.id,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: Value(role),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final row = await (_db.select(
|
|
||||||
_db.mailboxes,
|
|
||||||
)..where((t) => t.id.equals(id)))
|
|
||||||
.getSingle();
|
|
||||||
return _toModel(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<model.Mailbox> _createMailboxWithRoleJmap(
|
|
||||||
account_model.Account account,
|
|
||||||
String password,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final jmapUrl = account.jmapUrl;
|
|
||||||
if (jmapUrl == null || jmapUrl.isEmpty) {
|
|
||||||
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
|
||||||
}
|
|
||||||
final jmap = await JmapClient.connect(
|
|
||||||
httpClient: _httpClient,
|
|
||||||
jmapUrl: Uri.parse(jmapUrl),
|
|
||||||
username: _effectiveUsername(account),
|
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
final responses = await jmap.call([
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': jmap.accountId,
|
|
||||||
'create': {
|
|
||||||
'new-mailbox': {'name': name, 'role': role},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
final result = _responseArgs(responses, 0, 'Mailbox/set');
|
|
||||||
final created = result['created'] as Map<String, dynamic>?;
|
|
||||||
final newId =
|
|
||||||
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
|
|
||||||
if (newId == null) {
|
|
||||||
throw Exception(
|
|
||||||
'Failed to create mailbox "$name": server returned no ID',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final dbId = '${account.id}:$newId';
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
|
||||||
MailboxesCompanion.insert(
|
|
||||||
id: dbId,
|
|
||||||
accountId: account.id,
|
|
||||||
path: newId,
|
|
||||||
name: name,
|
|
||||||
role: Value(role),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final row = await (_db.select(
|
|
||||||
_db.mailboxes,
|
|
||||||
)..where((t) => t.id.equals(dbId)))
|
|
||||||
.getSingle();
|
|
||||||
return _toModel(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
|
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
// Remove existing entry for same query (deduplication).
|
// Remove existing entry for same query (deduplication).
|
||||||
await (_db.delete(
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
_db.searchHistoryEntries,
|
..where((t) => t.query.equals(trimmed)))
|
||||||
)..where((t) => t.query.equals(trimmed)))
|
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
if (keepIds.isNotEmpty) {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
_db.searchHistoryEntries,
|
..where((t) => t.id.isNotIn(keepIds)))
|
||||||
)..where((t) => t.id.isNotIn(keepIds)))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
await _pruneExpired();
|
await _pruneExpired();
|
||||||
|
|
||||||
final keyIdHex = _hex(keyId);
|
final keyIdHex = _hex(keyId);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.shareKeys)
|
||||||
_db.shareKeys,
|
..where((t) => t.id.equals(keyIdHex)))
|
||||||
)..where((t) => t.id.equals(keyIdHex)))
|
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (row == null) return null;
|
if (row == null) return null;
|
||||||
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pruneExpired() async {
|
Future<void> _pruneExpired() async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.shareKeys)
|
||||||
_db.shareKeys,
|
..where(
|
||||||
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||||
|
))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? stackTrace,
|
|
||||||
bool isPermanent = false,
|
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -32,8 +30,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
result: success ? 'ok' : 'error',
|
result: success ? 'ok' : 'error',
|
||||||
errorMessage: Value(errorMessage),
|
errorMessage: Value(errorMessage),
|
||||||
errorStackTrace: Value(stackTrace),
|
|
||||||
isPermanent: Value(isPermanent),
|
|
||||||
protocol: Value(protocol),
|
protocol: Value(protocol),
|
||||||
itemsSynced: Value(emailsFetched),
|
itemsSynced: Value(emailsFetched),
|
||||||
emailsSkipped: Value(emailsSkipped),
|
emailsSkipped: Value(emailsSkipped),
|
||||||
@@ -79,8 +75,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
result: r.result,
|
result: r.result,
|
||||||
errorMessage: r.errorMessage,
|
errorMessage: r.errorMessage,
|
||||||
stackTrace: r.errorStackTrace,
|
|
||||||
isPermanent: r.isPermanent,
|
|
||||||
protocol: r.protocol,
|
protocol: r.protocol,
|
||||||
emailsFetched: r.itemsSynced,
|
emailsFetched: r.itemsSynced,
|
||||||
emailsSkipped: r.emailsSkipped,
|
emailsSkipped: r.emailsSkipped,
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
|
|
||||||
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
|
||||||
|
|
||||||
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|
||||||
UserPreferencesRepositoryImpl(this._db);
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
|
||||||
static const _rowId = 1;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<pref.UserPreferences> observePreferences() {
|
|
||||||
return (_db.select(
|
|
||||||
_db.userPreferences,
|
|
||||||
)..where((t) => t.id.equals(_rowId)))
|
|
||||||
.watchSingleOrNull()
|
|
||||||
.map(_rowToModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> updateMenuPosition(pref.MenuPosition position) async {
|
|
||||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
|
||||||
UserPreferencesCompanion(
|
|
||||||
id: const Value(_rowId),
|
|
||||||
menuPosition: Value(position.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
|
||||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
|
||||||
UserPreferencesCompanion(
|
|
||||||
id: const Value(_rowId),
|
|
||||||
mailViewButtonPosition: Value(position.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> updateAfterMailViewAction(
|
|
||||||
pref.AfterMailViewAction action,
|
|
||||||
) async {
|
|
||||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
|
||||||
UserPreferencesCompanion(
|
|
||||||
id: const Value(_rowId),
|
|
||||||
afterMailViewAction: Value(action.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
|
||||||
if (row == null) return const pref.UserPreferences();
|
|
||||||
return pref.UserPreferences(
|
|
||||||
menuPosition: pref.MenuPosition.values.firstWhere(
|
|
||||||
(e) => e.name == row.menuPosition,
|
|
||||||
orElse: () => pref.MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
|
|
||||||
(e) => e.name == row.mailViewButtonPosition,
|
|
||||||
orElse: () => pref.MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
|
|
||||||
(e) => e.name == row.afterMailViewAction,
|
|
||||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-27
@@ -5,7 +5,6 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
@@ -14,7 +13,6 @@ import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
@@ -23,8 +21,7 @@ import 'package:sharedinbox/core/services/undo_service.dart';
|
|||||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart'
|
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
|
||||||
hide Email, EmailBody, UserPreferences;
|
|
||||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||||
@@ -36,7 +33,6 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar
|
|||||||
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
|
|
||||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
|
|
||||||
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
||||||
@@ -101,9 +97,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
final searchHistoryRepositoryProvider =
|
||||||
ref,
|
Provider<SearchHistoryRepository>((ref) {
|
||||||
) {
|
|
||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,10 +131,8 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
final isSyncingProvider =
|
||||||
ref,
|
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||||
accountId,
|
|
||||||
) {
|
|
||||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +181,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
final undoServiceProvider =
|
||||||
UndoService.new,
|
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||||
);
|
|
||||||
|
|
||||||
/// Loads email header + body and marks the email as seen.
|
/// Loads email header + body and marks the email as seen.
|
||||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||||
@@ -235,15 +227,3 @@ final accountConnectionStatusProvider =
|
|||||||
.read(connectionTestServiceProvider)
|
.read(connectionTestServiceProvider)
|
||||||
.testConnection(account, password);
|
.testConnection(account, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
|
||||||
});
|
|
||||||
|
|
||||||
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
@@ -57,10 +56,6 @@ final router = GoRouter(
|
|||||||
path: 'about',
|
path: 'about',
|
||||||
builder: (ctx, state) => const AboutScreen(),
|
builder: (ctx, state) => const AboutScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: 'preferences',
|
|
||||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -7,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AboutScreen extends ConsumerStatefulWidget {
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
@@ -19,22 +19,57 @@ class AboutScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
late final Future<String?> _deviceModelFuture;
|
|
||||||
late final Stream<List<Account>> _accountsStream;
|
late final Stream<List<Account>> _accountsStream;
|
||||||
String? _deviceModel;
|
|
||||||
|
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
_deviceModelFuture = getDeviceModel();
|
|
||||||
unawaited(
|
|
||||||
_deviceModelFuture.then((model) {
|
|
||||||
if (mounted) setState(() => _deviceModel = model);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _buildMarkdown(
|
||||||
|
BuildContext context,
|
||||||
|
PackageInfo? pkg,
|
||||||
|
int imapCount,
|
||||||
|
int jmapCount,
|
||||||
|
) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final physW = (size.width * pixelRatio).toInt();
|
||||||
|
final physH = (size.height * pixelRatio).toInt();
|
||||||
|
final version =
|
||||||
|
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||||
|
final versionDisplay = _gitHash.isNotEmpty
|
||||||
|
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||||
|
: version;
|
||||||
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
|
||||||
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
: '';
|
||||||
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
|
'| Property | Value |\n'
|
||||||
|
'|----------|-------|\n'
|
||||||
|
'| App Version | $versionDisplay |\n'
|
||||||
|
'$gitCommitLine'
|
||||||
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
|
'| Resolution | ${physW}x$physH px'
|
||||||
|
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||||
|
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||||
|
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||||
|
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||||
|
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||||
|
'| IMAP Accounts | $imapCount |\n'
|
||||||
|
'| JMAP Accounts | $jmapCount |\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _capitalize(String s) =>
|
||||||
|
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||||
|
|
||||||
Future<void> _copyToClipboard(
|
Future<void> _copyToClipboard(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -44,20 +79,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
String? deviceModel;
|
|
||||||
try {
|
|
||||||
deviceModel = await _deviceModelFuture;
|
|
||||||
} catch (_) {}
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
text: buildAboutMarkdown(
|
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||||
context: context,
|
|
||||||
pkg: pkg,
|
|
||||||
imapCount: imapCount,
|
|
||||||
jmapCount: jmapCount,
|
|
||||||
deviceModel: deviceModel,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -70,32 +95,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
|
||||||
try {
|
|
||||||
final launched = await launchUrl(
|
|
||||||
url,
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text('Could not open browser.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
content: Text('Error: $e'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _createIssue(
|
Future<void> _createIssue(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -105,28 +104,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
String? deviceModel;
|
|
||||||
try {
|
|
||||||
deviceModel = await _deviceModelFuture;
|
|
||||||
} catch (_) {}
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final body = Uri.encodeComponent(
|
final body = Uri.encodeComponent(
|
||||||
buildAboutMarkdown(
|
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||||
context: context,
|
|
||||||
pkg: pkg,
|
|
||||||
imapCount: imapCount,
|
|
||||||
jmapCount: jmapCount,
|
|
||||||
deviceModel: deviceModel,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
final url = Uri.parse(
|
final url = Uri.parse(
|
||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final launched = await launchUrl(
|
final launched =
|
||||||
url,
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -170,17 +157,21 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
return Markdown(
|
return Markdown(
|
||||||
data: buildAboutMarkdown(
|
data: _buildMarkdown(
|
||||||
context: context,
|
context,
|
||||||
pkg: snapshot.data,
|
snapshot.data,
|
||||||
imapCount: imapCount,
|
imapCount,
|
||||||
jmapCount: jmapCount,
|
jmapCount,
|
||||||
deviceModel: _deviceModel,
|
|
||||||
),
|
),
|
||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(_launchUrl(context, Uri.parse(href)));
|
unawaited(
|
||||||
|
launchUrl(
|
||||||
|
Uri.parse(href),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -67,14 +66,6 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
unawaited(context.push('/accounts/about'));
|
unawaited(context.push('/accounts/about'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.settings),
|
|
||||||
title: const Text('Preferences'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context); // Close drawer
|
|
||||||
unawaited(context.push('/accounts/preferences'));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -120,80 +111,20 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
final health = ref.watch(syncHealthProvider(account.id));
|
final health = ref.watch(syncHealthProvider(account.id));
|
||||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||||
|
|
||||||
return Column(
|
return ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
leading: const Icon(Icons.account_circle),
|
||||||
children: [
|
title: Text(account.displayName),
|
||||||
ListTile(
|
subtitle: Column(
|
||||||
leading: const Icon(Icons.account_circle),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text(account.displayName),
|
children: [
|
||||||
subtitle: Text('${account.email}\n$typeLabel'),
|
Text('${account.email}\n$typeLabel'),
|
||||||
isThreeLine: true,
|
const SizedBox(height: 4),
|
||||||
trailing: Row(
|
health.when(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
status.when(
|
|
||||||
loading: () => const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
data: (_) =>
|
|
||||||
const Icon(Icons.check_circle, color: Colors.green),
|
|
||||||
error: (e, _) => Tooltip(
|
|
||||||
message: e.toString(),
|
|
||||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuButton<_AccountAction>(
|
|
||||||
onSelected: (action) => _onAction(context, action),
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.syncLog,
|
|
||||||
child: Text('Sync log'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.verifySync,
|
|
||||||
child: Text('Verify sync health'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.forceSync,
|
|
||||||
child: Text('Force full sync'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.edit,
|
|
||||||
child: Text('Edit'),
|
|
||||||
),
|
|
||||||
if (_sieveSupported(account))
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersRemote,
|
|
||||||
child: Text('Server email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersLocal,
|
|
||||||
child: Text('Local email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.send,
|
|
||||||
child: Text('Send accounts'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.delete,
|
|
||||||
child: Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
|
||||||
child: health.when(
|
|
||||||
data: (h) {
|
data: (h) {
|
||||||
if (h == null) return const Text('Sync health: Not verified yet');
|
if (h == null) return const Text('Sync health: Not verified yet');
|
||||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||||
return Row(
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text('Sync health: '),
|
const Text('Sync health: '),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -202,13 +133,7 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
||||||
child: Text(
|
|
||||||
h.isHealthy
|
|
||||||
? 'Healthy'
|
|
||||||
: _formatDiscrepancies(h.discrepancySummary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -216,8 +141,66 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
loading: () => const Text('Sync health: checking...'),
|
loading: () => const Text('Sync health: checking...'),
|
||||||
error: (e, _) => Text('Sync health error: $e'),
|
error: (e, _) => Text('Sync health error: $e'),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
status.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
||||||
|
error: (e, _) => Tooltip(
|
||||||
|
message: e.toString(),
|
||||||
|
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_AccountAction>(
|
||||||
|
onSelected: (action) => _onAction(context, action),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.syncLog,
|
||||||
|
child: Text('Sync log'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.verifySync,
|
||||||
|
child: Text('Verify sync health'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.forceSync,
|
||||||
|
child: Text('Force full sync'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.edit,
|
||||||
|
child: Text('Edit'),
|
||||||
|
),
|
||||||
|
if (_sieveSupported(account))
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersRemote,
|
||||||
|
child: Text('Server email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersLocal,
|
||||||
|
child: Text('Local email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.send,
|
||||||
|
child: Text('Send accounts'),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.delete,
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,30 +293,6 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDiscrepancies(String? summary) {
|
|
||||||
if (summary == null) return 'Discrepancies found';
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(summary) as Map<String, dynamic>;
|
|
||||||
var missingLocally = 0;
|
|
||||||
var missingOnServer = 0;
|
|
||||||
var flagMismatches = 0;
|
|
||||||
for (final v in decoded.values) {
|
|
||||||
final m = v as Map<String, dynamic>;
|
|
||||||
missingLocally += (m['missingLocally'] as int? ?? 0);
|
|
||||||
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
|
||||||
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
|
||||||
}
|
|
||||||
final parts = <String>[];
|
|
||||||
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
|
|
||||||
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
|
|
||||||
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
|
|
||||||
if (parts.isEmpty) return 'Discrepancies found';
|
|
||||||
return 'Discrepancies found (${parts.join(', ')})';
|
|
||||||
} catch (_) {
|
|
||||||
return 'Discrepancies found';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OnboardingView extends StatelessWidget {
|
class _OnboardingView extends StatelessWidget {
|
||||||
const _OnboardingView();
|
const _OnboardingView();
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
|||||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||||
_Step _step = _Step.generatingKey;
|
_Step _step = _Step.generatingKey;
|
||||||
ShareKeyMaterial? _keyMaterial;
|
ShareKeyMaterial? _keyMaterial;
|
||||||
DateTime? _keyExpiresAt;
|
|
||||||
String? _pubKeyQr;
|
String? _pubKeyQr;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _scannerActive = false;
|
bool _scannerActive = false;
|
||||||
@@ -65,7 +64,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_keyMaterial = material;
|
_keyMaterial = material;
|
||||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
|
||||||
_pubKeyQr = qr;
|
_pubKeyQr = qr;
|
||||||
_step = _Step.showingPubKey;
|
_step = _Step.showingPubKey;
|
||||||
});
|
});
|
||||||
@@ -87,24 +85,22 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
|
||||||
// the plugin exists but something else failed — the MobileScanner widget
|
|
||||||
// will surface those via its own error builder.
|
|
||||||
Future<void> _initScanner() async {
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
bool available = false;
|
bool available = false;
|
||||||
try {
|
try {
|
||||||
await const MethodChannel(
|
ctrl = MobileScannerController();
|
||||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
await ctrl.start();
|
||||||
).invokeMethod<int>('state');
|
await ctrl.stop();
|
||||||
available = true;
|
available = true;
|
||||||
} on MissingPluginException {
|
|
||||||
// Plugin not registered on this device; text fallback will be shown.
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Plugin registered but state check failed; let the scanner widget
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
// handle it via its errorBuilder.
|
} finally {
|
||||||
available = true;
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (available) {
|
if (available) {
|
||||||
@@ -219,7 +215,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Step.done => const Center(
|
_Step.done => const Center(
|
||||||
child: Icon(Icons.check_circle, size: 64, color: Colors.green),
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Step.error => Center(
|
_Step.error => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -274,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
const _ExpiryHint(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
Text(
|
Text(
|
||||||
@@ -404,37 +404,8 @@ bool _cameraScanSupported() =>
|
|||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows;
|
Platform.isWindows;
|
||||||
|
|
||||||
class _ExpiryHint extends StatefulWidget {
|
class _ExpiryHint extends StatelessWidget {
|
||||||
const _ExpiryHint({required this.expiresAt});
|
const _ExpiryHint();
|
||||||
|
|
||||||
final DateTime expiresAt;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ExpiryHint> createState() => _ExpiryHintState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExpiryHintState extends State<_ExpiryHint> {
|
|
||||||
late Timer _timer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_timer.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatRemaining() {
|
|
||||||
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
|
||||||
if (remaining.isNegative) return 'expired';
|
|
||||||
final minutes = remaining.inMinutes;
|
|
||||||
final seconds = remaining.inSeconds % 60;
|
|
||||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -444,7 +415,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
|
|||||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'This key expires in ${_formatRemaining()}',
|
'This key expires in 20 minutes',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -57,24 +57,22 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
|
||||||
// the plugin exists but something else failed — the MobileScanner widget
|
|
||||||
// will surface those via its own error builder.
|
|
||||||
Future<void> _initScanner() async {
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
bool available = false;
|
bool available = false;
|
||||||
try {
|
try {
|
||||||
await const MethodChannel(
|
ctrl = MobileScannerController();
|
||||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
await ctrl.start();
|
||||||
).invokeMethod<int>('state');
|
await ctrl.stop();
|
||||||
available = true;
|
available = true;
|
||||||
} on MissingPluginException {
|
|
||||||
// Plugin not registered on this device; text fallback will be shown.
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Plugin registered but state check failed; let the scanner widget
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
// handle it via its errorBuilder.
|
} finally {
|
||||||
available = true;
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (available) {
|
if (available) {
|
||||||
@@ -158,7 +156,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
for (final account in selected) {
|
for (final account in selected) {
|
||||||
final password = await repo.getPassword(account.id);
|
final password = await repo.getPassword(account.id);
|
||||||
payloads.add(
|
payloads.add(
|
||||||
AccountPayload(accountJson: account.toJson(), password: password),
|
AccountPayload(
|
||||||
|
accountJson: account.toJson(),
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +359,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Encrypted code copied to clipboard'),
|
content: Text(
|
||||||
|
'Encrypted code copied to clipboard',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -12,9 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: DefaultAssetBundle.of(
|
future: rootBundle.loadString('assets/changelog.txt'),
|
||||||
context,
|
|
||||||
).loadString('assets/changelog.txt'),
|
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -194,7 +194,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
await OpenFilex.open(path);
|
await OpenFilex.open(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Failed to open file: $e'),
|
content: Text('Failed to open file: $e'),
|
||||||
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Select an account first'),
|
content: Text('Select an account first'),
|
||||||
@@ -251,7 +255,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Send failed: $e'),
|
content: Text('Send failed: $e'),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -18,23 +17,12 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
final String gitHash;
|
final String gitHash;
|
||||||
|
|
||||||
String get _buildMode {
|
Future<String> _buildReport() async {
|
||||||
if (kDebugMode) return 'debug';
|
String version = 'unknown';
|
||||||
if (kProfileMode) return 'profile';
|
|
||||||
return 'release';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _fetchVersion() async {
|
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
return '${info.version}+${info.buildNumber}';
|
version = '${info.version}+${info.buildNumber}';
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
|
||||||
final version = await _fetchVersion();
|
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
final versionDisplay = gitHash.isNotEmpty
|
final versionDisplay = gitHash.isNotEmpty
|
||||||
@@ -43,13 +31,9 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final gitLine = gitHash.isNotEmpty
|
final gitLine = gitHash.isNotEmpty
|
||||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||||
: '';
|
: '';
|
||||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
|
||||||
return 'App Version: $versionDisplay\n'
|
return 'App Version: $versionDisplay\n'
|
||||||
'Build Mode: $_buildMode\n'
|
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n'
|
'Platform: $platform\n\n'
|
||||||
'Dart: ${Platform.version}\n'
|
|
||||||
'Timestamp: $timestamp\n\n'
|
|
||||||
'Error:\n```\n$exception\n```\n\n'
|
'Error:\n```\n$exception\n```\n\n'
|
||||||
'Stack Trace:\n```\n$stackTrace\n```';
|
'Stack Trace:\n```\n$stackTrace\n```';
|
||||||
}
|
}
|
||||||
@@ -75,18 +59,6 @@ class CrashScreen extends StatelessWidget {
|
|||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
FutureBuilder<String>(
|
|
||||||
future: _fetchVersion(),
|
|
||||||
builder: (context, snapshot) => Text(
|
|
||||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (gitHash.isNotEmpty) ...[
|
if (gitHash.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FutureBuilder<PackageInfo>(
|
FutureBuilder<PackageInfo>(
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.addListener(_rebuild);
|
_smtpHostCtrl.addListener(_rebuild);
|
||||||
_sieveHostCtrl.addListener(_rebuild);
|
_sieveHostCtrl.addListener(_rebuild);
|
||||||
_imapHostCtrl.addListener(_rebuild);
|
_imapHostCtrl.addListener(_rebuild);
|
||||||
_passwordCtrl.addListener(_rebuild);
|
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +90,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.removeListener(_rebuild);
|
_smtpHostCtrl.removeListener(_rebuild);
|
||||||
_sieveHostCtrl.removeListener(_rebuild);
|
_sieveHostCtrl.removeListener(_rebuild);
|
||||||
_imapHostCtrl.removeListener(_rebuild);
|
_imapHostCtrl.removeListener(_rebuild);
|
||||||
_passwordCtrl.removeListener(_rebuild);
|
|
||||||
for (final c in [
|
for (final c in [
|
||||||
_displayNameCtrl,
|
_displayNameCtrl,
|
||||||
_usernameCtrl,
|
_usernameCtrl,
|
||||||
@@ -355,17 +353,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
testing: _tryTesting,
|
testing: _tryTesting,
|
||||||
okMessage: _tryOk,
|
okMessage: _tryOk,
|
||||||
errorMessage: _tryErr,
|
errorMessage: _tryErr,
|
||||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
onPressed: _tryConnection,
|
||||||
? _tryConnection
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
|
||||||
? _save
|
|
||||||
: null,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|
||||||
|
|
||||||
enum _MissingFolderChoice { chooseExisting, createNew }
|
|
||||||
|
|
||||||
/// Resolves a mailbox by role, prompting the user to choose or create one when
|
|
||||||
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
|
|
||||||
Future<Mailbox?> resolveMailboxByRole(
|
|
||||||
BuildContext context,
|
|
||||||
MailboxRepository mailboxRepo,
|
|
||||||
String accountId,
|
|
||||||
String currentMailboxPath,
|
|
||||||
String role, {
|
|
||||||
required String dialogTitle,
|
|
||||||
required String createFolderName,
|
|
||||||
}) async {
|
|
||||||
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
|
|
||||||
if (!context.mounted) return null;
|
|
||||||
if (mailbox != null) return mailbox;
|
|
||||||
|
|
||||||
final choice = await showDialog<_MissingFolderChoice>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text(dialogTitle),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
|
|
||||||
child: const Text('Choose existing folder'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
|
|
||||||
child: Text('Create "$createFolderName"'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!context.mounted || choice == null) return null;
|
|
||||||
|
|
||||||
switch (choice) {
|
|
||||||
case _MissingFolderChoice.chooseExisting:
|
|
||||||
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
|
|
||||||
if (!context.mounted) return null;
|
|
||||||
final chosen = await showModalBottomSheet<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: [
|
|
||||||
const ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Move to…',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final m in mailboxes.where(
|
|
||||||
(m) => m.path != currentMailboxPath,
|
|
||||||
))
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.folder_outlined),
|
|
||||||
title: Text(m.name),
|
|
||||||
onTap: () => Navigator.pop(ctx, m.path),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (chosen == null || !context.mounted) return null;
|
|
||||||
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
|
|
||||||
case _MissingFolderChoice.createNew:
|
|
||||||
mailbox = await mailboxRepo.createMailboxWithRole(
|
|
||||||
accountId,
|
|
||||||
createFolderName,
|
|
||||||
role,
|
|
||||||
);
|
|
||||||
if (!context.mounted) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mailbox;
|
|
||||||
}
|
|
||||||
@@ -13,11 +13,9 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -72,23 +70,61 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_replyWithRecipientDialog(context, header, body));
|
unawaited(_reply(context, header, body, replyAll: false));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.archive),
|
icon: const Icon(Icons.reply_all),
|
||||||
tooltip: 'Archive',
|
tooltip: 'Reply all',
|
||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_archive(context, header));
|
unawaited(_reply(context, header, body, replyAll: true));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.forward),
|
||||||
|
tooltip: 'Forward',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_forward(context, header, body));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||||
|
tooltip: 'Mark as unread',
|
||||||
|
onPressed: () async {
|
||||||
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFlagged ? Icons.star : Icons.star_border,
|
||||||
|
color: _isFlagged ? Colors.amber : null,
|
||||||
|
),
|
||||||
|
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
||||||
|
onPressed: () async {
|
||||||
|
final next = !_isFlagged;
|
||||||
|
await repo.setFlag(widget.emailId, flagged: next);
|
||||||
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.drive_file_move_outline),
|
||||||
|
tooltip: 'Move to folder',
|
||||||
|
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed: header == null ? null : () => _snooze(context, header),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
final destPath = await repo.deleteEmail(widget.emailId);
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
@@ -107,32 +143,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_isFlagged ? Icons.star : Icons.star_border,
|
|
||||||
color: _isFlagged ? Colors.amber : null,
|
|
||||||
),
|
|
||||||
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
|
||||||
onPressed: () async {
|
|
||||||
final next = !_isFlagged;
|
|
||||||
await repo.setFlag(widget.emailId, flagged: next);
|
|
||||||
if (mounted) setState(() => _isFlagged = next);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
|
||||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
|
||||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
|
||||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'mark_unread',
|
|
||||||
child: Text('Mark as unread'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
child: Text('Show Mail Headers'),
|
child: Text('Show Mail Headers'),
|
||||||
@@ -141,22 +156,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
value: 'structure',
|
value: 'structure',
|
||||||
child: Text('Show Mail Structure'),
|
child: Text('Show Mail Structure'),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
const PopupMenuItem(
|
||||||
|
value: 'rfc',
|
||||||
|
child: Text('Show Raw Email'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
onSelected: (value) {
|
||||||
if (value == 'forward' && header != null) {
|
if (value == 'headers' && body != null) {
|
||||||
unawaited(_forward(context, header, body));
|
|
||||||
} else if (value == 'move' && header != null) {
|
|
||||||
unawaited(_moveTo(context, header));
|
|
||||||
} else if (value == 'snooze' && header != null) {
|
|
||||||
unawaited(_snooze(context, header));
|
|
||||||
} else if (value == 'spam' && header != null) {
|
|
||||||
unawaited(_markAsSpam(context, header));
|
|
||||||
} else if (value == 'mark_unread') {
|
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
|
||||||
} else if (value == 'headers' && body != null) {
|
|
||||||
_showHeaders(context, body);
|
_showHeaders(context, body);
|
||||||
} else if (value == 'structure' && body != null) {
|
} else if (value == 'structure' && body != null) {
|
||||||
_showStructure(context, body);
|
_showStructure(context, body);
|
||||||
@@ -235,40 +241,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
|
|
||||||
if (header == null) return null;
|
|
||||||
final prefs = ref.read(userPreferencesProvider).value;
|
|
||||||
final action =
|
|
||||||
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
|
||||||
if (action != AfterMailViewAction.nextMessage) return null;
|
|
||||||
|
|
||||||
final threads = await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.observeThreads(header.accountId, header.mailboxPath)
|
|
||||||
.first;
|
|
||||||
|
|
||||||
final currentIndex = threads.indexWhere(
|
|
||||||
(t) => t.emailIds.contains(widget.emailId),
|
|
||||||
);
|
|
||||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
|
||||||
return threads[currentIndex + 1].latestEmailId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
if (nextEmailId != null && header != null) {
|
|
||||||
context.go(
|
|
||||||
'/accounts/${header.accountId}'
|
|
||||||
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(nextEmailId)}',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
||||||
setState(() => _downloading.add(att.filename));
|
setState(() => _downloading.add(att.filename));
|
||||||
try {
|
try {
|
||||||
@@ -331,78 +303,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _replyWithRecipientDialog(
|
Future<void> _reply(
|
||||||
BuildContext context,
|
|
||||||
Email header,
|
|
||||||
EmailBody? body,
|
|
||||||
) async {
|
|
||||||
final account =
|
|
||||||
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
|
||||||
final ownEmail = account?.email.toLowerCase() ?? '';
|
|
||||||
|
|
||||||
final seen = <String>{};
|
|
||||||
final candidates = <_Candidate>[];
|
|
||||||
|
|
||||||
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
|
|
||||||
final key = addr.email.toLowerCase();
|
|
||||||
if (key == ownEmail || seen.contains(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
candidates.add(_Candidate(addr, defaultPlacement));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final addr in header.from) {
|
|
||||||
addIfNew(addr, _Placement.to);
|
|
||||||
}
|
|
||||||
for (final addr in header.to) {
|
|
||||||
addIfNew(addr, _Placement.to);
|
|
||||||
}
|
|
||||||
for (final addr in header.cc) {
|
|
||||||
addIfNew(addr, _Placement.cc);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
if (candidates.length <= 1) {
|
|
||||||
final to = candidates
|
|
||||||
.where((c) => c.placement == _Placement.to)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
final cc = candidates
|
|
||||||
.where((c) => c.placement == _Placement.cc)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
await _composeReply(context, header, body, to: to, cc: cc);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final confirmed = await showDialog<List<_Candidate>>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == null || !context.mounted) return;
|
|
||||||
|
|
||||||
final to = confirmed
|
|
||||||
.where((c) => c.placement == _Placement.to)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
final cc = confirmed
|
|
||||||
.where((c) => c.placement == _Placement.cc)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
await _composeReply(context, header, body, to: to, cc: cc);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _composeReply(
|
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required String to,
|
required bool replyAll,
|
||||||
required String cc,
|
|
||||||
}) async {
|
}) async {
|
||||||
|
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Re: ${header.subject ?? ''}';
|
: 'Re: ${header.subject ?? ''}';
|
||||||
|
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||||
final quoted = await _quotedBody(header, body);
|
final quoted = await _quotedBody(header, body);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -419,78 +330,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _archive(BuildContext context, Email header) async {
|
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
|
||||||
context,
|
|
||||||
ref.read(mailboxRepositoryProvider),
|
|
||||||
header.accountId,
|
|
||||||
header.mailboxPath,
|
|
||||||
'archive',
|
|
||||||
dialogTitle: 'No archive folder found',
|
|
||||||
createFolderName: 'Archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mailbox == null || !context.mounted) return;
|
|
||||||
|
|
||||||
await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.moveEmail(widget.emailId, mailbox.path);
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: header.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: [widget.emailId],
|
|
||||||
sourceMailboxPath: header.mailboxPath,
|
|
||||||
destinationMailboxPath: mailbox.path,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
|
||||||
context,
|
|
||||||
ref.read(mailboxRepositoryProvider),
|
|
||||||
header.accountId,
|
|
||||||
header.mailboxPath,
|
|
||||||
'junk',
|
|
||||||
dialogTitle: 'No spam folder found',
|
|
||||||
createFolderName: 'Junk',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mailbox == null || !context.mounted) return;
|
|
||||||
|
|
||||||
await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.moveEmail(widget.emailId, mailbox.path);
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: header.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: [widget.emailId],
|
|
||||||
sourceMailboxPath: header.mailboxPath,
|
|
||||||
destinationMailboxPath: mailbox.path,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _forward(
|
Future<void> _forward(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
@@ -504,14 +343,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
extra: {
|
||||||
|
'prefillSubject': subject,
|
||||||
|
'prefillBody': quoted,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _moveTo(BuildContext context, Email header) async {
|
Future<void> _moveTo(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
|
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
final mailboxes =
|
final mailboxes =
|
||||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||||
@@ -560,13 +400,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _snooze(BuildContext context, Email header) async {
|
Future<void> _snooze(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final until = await showModalBottomSheet<DateTime>(
|
final until = await showModalBottomSheet<DateTime>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => const SnoozePicker(),
|
builder: (ctx) => const SnoozePicker(),
|
||||||
@@ -594,7 +431,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_navigateTo(context, header, nextEmailId);
|
context.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,9 +443,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
.fetchRawRfc822(widget.emailId);
|
.fetchRawRfc822(widget.emailId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,7 +610,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Structure not available. Try re-syncing the email.'),
|
content: Text(
|
||||||
|
'Structure not available. Try re-syncing the email.',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -831,88 +670,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Placement { to, cc, skip }
|
|
||||||
|
|
||||||
class _Candidate {
|
|
||||||
_Candidate(this.address, this.placement);
|
|
||||||
final EmailAddress address;
|
|
||||||
_Placement placement;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReplyAllDialog extends StatefulWidget {
|
|
||||||
const _ReplyAllDialog({required this.candidates});
|
|
||||||
final List<_Candidate> candidates;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
|
||||||
late final List<_Candidate> _candidates;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_candidates = [
|
|
||||||
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Reply All'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: [
|
|
||||||
for (final c in _candidates)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
c.address.toString(),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SegmentedButton<_Placement>(
|
|
||||||
showSelectedIcon: false,
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(value: _Placement.to, label: Text('To')),
|
|
||||||
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
|
||||||
ButtonSegment(
|
|
||||||
value: _Placement.skip,
|
|
||||||
label: Text('Skip'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {c.placement},
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => c.placement = s.first),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, _candidates),
|
|
||||||
child: const Text('Reply'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MimeRow {
|
class _MimeRow {
|
||||||
const _MimeRow(this.depth, this.label);
|
const _MimeRow(this.depth, this.label);
|
||||||
final int depth;
|
final int depth;
|
||||||
@@ -955,13 +712,10 @@ class _UnsubscribeChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final uri = _parseUnsubscribeUri(header);
|
final uri = _parseUnsubscribeUri(header);
|
||||||
if (uri == null) return const SizedBox.shrink();
|
if (uri == null) return const SizedBox.shrink();
|
||||||
return Tooltip(
|
return ActionChip(
|
||||||
message: uri.toString(),
|
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||||
child: ActionChip(
|
label: const Text('Unsubscribe'),
|
||||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||||
label: const Text('Unsubscribe'),
|
|
||||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
@@ -149,21 +147,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||||
final prefs =
|
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
|
||||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
appBar: _buildAppBar(repo, accountAsync),
|
||||||
drawer: _selecting
|
drawer: _selecting
|
||||||
? null
|
? null
|
||||||
: FolderDrawer(
|
: FolderDrawer(
|
||||||
accountId: widget.accountId,
|
accountId: widget.accountId,
|
||||||
currentMailboxPath: widget.mailboxPath,
|
currentMailboxPath: widget.mailboxPath,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _selecting
|
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||||
? _selectionBottomBar()
|
|
||||||
: (menuAtBottom ? _folderNavBottomBar() : null),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSyncErrorBanner(),
|
_buildSyncErrorBanner(),
|
||||||
@@ -179,14 +172,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(
|
PreferredSizeWidget _buildAppBar(
|
||||||
EmailRepository emailRepo,
|
EmailRepository emailRepo,
|
||||||
AsyncValue<Account?> accountAsync, {
|
AsyncValue<Account?> accountAsync,
|
||||||
required bool menuAtBottom,
|
) {
|
||||||
}) {
|
|
||||||
final selectionCount =
|
final selectionCount =
|
||||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
automaticallyImplyLeading: !menuAtBottom,
|
|
||||||
leading: _selecting
|
leading: _selecting
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -309,22 +300,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _folderNavBottomBar() {
|
|
||||||
return BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Builder(
|
|
||||||
builder: (context) => IconButton(
|
|
||||||
icon: const Icon(Icons.menu),
|
|
||||||
tooltip: 'Open folders',
|
|
||||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _selectionBottomBar() {
|
Widget _selectionBottomBar() {
|
||||||
return BottomAppBar(
|
return BottomAppBar(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -381,7 +356,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
return MaterialBanner(
|
return MaterialBanner(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
|
content: Text(
|
||||||
|
error,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.sync_problem,
|
Icons.sync_problem,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
@@ -395,8 +374,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () => context.push(
|
||||||
context.push('/accounts/${widget.accountId}/sync-log'),
|
'/accounts/${widget.accountId}/sync-log',
|
||||||
|
),
|
||||||
child: const Text('View log'),
|
child: const Text('View log'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -440,26 +420,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMoveToRole(
|
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
|
||||||
String role, {
|
|
||||||
required String dialogTitle,
|
|
||||||
required String createFolderName,
|
|
||||||
}) async {
|
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
|
final mailbox = await ref
|
||||||
final mailbox = await resolveMailboxByRole(
|
.read(mailboxRepositoryProvider)
|
||||||
context,
|
.findMailboxByRole(widget.accountId, role);
|
||||||
ref.read(mailboxRepositoryProvider),
|
if (!mounted) return;
|
||||||
widget.accountId,
|
if (mailbox == null) {
|
||||||
widget.mailboxPath,
|
ScaffoldMessenger.of(
|
||||||
role,
|
context,
|
||||||
dialogTitle: dialogTitle,
|
).showSnackBar(
|
||||||
createFolderName: createFolderName,
|
SnackBar(
|
||||||
);
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text(notFoundMessage),
|
||||||
if (!mounted || mailbox == null) return;
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
||||||
@@ -485,11 +463,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchArchive() => _batchMoveToRole(
|
Future<void> _batchArchive() =>
|
||||||
'archive',
|
_batchMoveToRole('archive', 'No archive folder found');
|
||||||
dialogTitle: 'No archive folder found',
|
|
||||||
createFolderName: 'Archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||||
if (!mounted || !_searching) return;
|
if (!mounted || !_searching) return;
|
||||||
@@ -568,11 +543,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
Future<void> _batchMarkSpam() =>
|
||||||
'junk',
|
_batchMoveToRole('junk', 'No spam folder found');
|
||||||
dialogTitle: 'No spam folder found',
|
|
||||||
createFolderName: 'Junk',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _batchMove() async {
|
Future<void> _batchMove() async {
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
||||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
||||||
final prefs =
|
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
|
||||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: !menuAtBottom,
|
|
||||||
title: const Text('Folders'),
|
title: const Text('Folders'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -47,19 +42,6 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: FolderDrawer(accountId: accountId),
|
drawer: FolderDrawer(accountId: accountId),
|
||||||
bottomNavigationBar: menuAtBottom
|
|
||||||
? BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.menu),
|
|
||||||
tooltip: 'Open folders',
|
|
||||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── Failed-mutation banner ───────────────────────────────────────
|
// ── Failed-mutation banner ───────────────────────────────────────
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
|
|||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
|
|
||||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
final _searchHistoryProvider =
|
||||||
ref,
|
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||||
) async {
|
|
||||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
title: Text(
|
||||||
|
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
|
||||||
|
|
||||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||||
|
|
||||||
@@ -25,57 +21,6 @@ String _fmtBytes(int bytes) {
|
|||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
|
||||||
final buf = StringBuffer();
|
|
||||||
buf.writeln('## Sync Entry');
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln('| Property | Value |');
|
|
||||||
buf.writeln('|----------|-------|');
|
|
||||||
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
|
|
||||||
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
|
|
||||||
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
|
||||||
if (entry.protocol.isNotEmpty) {
|
|
||||||
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
|
||||||
}
|
|
||||||
final statusLabel = entry.isOk
|
|
||||||
? 'OK'
|
|
||||||
: entry.isPermanent
|
|
||||||
? 'Error (permanent)'
|
|
||||||
: 'Error';
|
|
||||||
buf.writeln('| Status | $statusLabel |');
|
|
||||||
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
|
||||||
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
|
||||||
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
|
||||||
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
|
||||||
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
|
||||||
if (entry.mailboxStats.isNotEmpty) {
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln('### Per mailbox');
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
|
||||||
buf.writeln('|---------|---------|------------|----------|');
|
|
||||||
for (final m in entry.mailboxStats) {
|
|
||||||
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
|
|
||||||
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.errorMessage != null) {
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln('**Error:**');
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln(entry.errorMessage);
|
|
||||||
}
|
|
||||||
if (entry.stackTrace != null) {
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln('**Stack trace:**');
|
|
||||||
buf.writeln();
|
|
||||||
buf.writeln('```');
|
|
||||||
buf.write(entry.stackTrace);
|
|
||||||
buf.writeln('```');
|
|
||||||
}
|
|
||||||
return buf.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||||
const SyncLogScreen({super.key, required this.accountId});
|
const SyncLogScreen({super.key, required this.accountId});
|
||||||
|
|
||||||
@@ -124,41 +69,6 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
|
||||||
final accounts =
|
|
||||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
|
||||||
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
|
||||||
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
|
||||||
|
|
||||||
PackageInfo? pkg;
|
|
||||||
try {
|
|
||||||
pkg = await PackageInfo.fromPlatform();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
final deviceModel = await getDeviceModel();
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final syncMd = _buildSyncEntryMarkdown(entry);
|
|
||||||
final aboutMd = buildAboutMarkdown(
|
|
||||||
context: context,
|
|
||||||
pkg: pkg,
|
|
||||||
imapCount: imapCount,
|
|
||||||
jmapCount: jmapCount,
|
|
||||||
deviceModel: deviceModel,
|
|
||||||
);
|
|
||||||
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 3),
|
|
||||||
content: Text('Copied to clipboard'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -186,20 +96,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
? const Center(child: Text('No sync entries yet'))
|
? const Center(child: Text('No sync entries yet'))
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: _entries.length,
|
itemCount: _entries.length,
|
||||||
itemBuilder: (ctx, i) => _SyncLogTile(
|
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
|
||||||
entry: _entries[i],
|
|
||||||
onCopy: () => _copyEntry(_entries[i], ctx),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SyncLogTile extends StatelessWidget {
|
class _SyncLogTile extends StatelessWidget {
|
||||||
const _SyncLogTile({required this.entry, required this.onCopy});
|
const _SyncLogTile({required this.entry});
|
||||||
|
|
||||||
final SyncLogEntry entry;
|
final SyncLogEntry entry;
|
||||||
final VoidCallback onCopy;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -209,12 +115,6 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final errorColor = theme.colorScheme.error;
|
final errorColor = theme.colorScheme.error;
|
||||||
|
|
||||||
final subtitleText = entry.isOk
|
|
||||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
|
||||||
: entry.isPermanent
|
|
||||||
? 'Error (permanent) · took $durationLabel'
|
|
||||||
: 'Error · took $durationLabel';
|
|
||||||
|
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
||||||
@@ -225,20 +125,11 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: entry.isOk ? null : TextStyle(color: errorColor),
|
style: entry.isOk ? null : TextStyle(color: errorColor),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
subtitleText,
|
entry.isOk
|
||||||
|
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||||
|
: 'Error · took $durationLabel',
|
||||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||||
),
|
),
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
|
||||||
tooltip: 'Copy as markdown',
|
|
||||||
onPressed: onCopy,
|
|
||||||
),
|
|
||||||
const Icon(Icons.expand_more),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
||||||
@@ -280,31 +171,6 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: TextStyle(color: errorColor, fontSize: 12),
|
style: TextStyle(color: errorColor, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (entry.stackTrace != null) ...[
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
|
||||||
child: Text(
|
|
||||||
'Stack trace',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black87,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
entry.stackTrace!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: Colors.red[300],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (entry.protocolLog != null) ...[
|
if (entry.protocolLog != null) ...[
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
@@ -29,16 +28,9 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
final prefs =
|
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
|
||||||
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Thread')),
|
||||||
title: const Text('Thread'),
|
|
||||||
automaticallyImplyLeading: !buttonAtBottom,
|
|
||||||
),
|
|
||||||
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
|
|
||||||
body: StreamBuilder<List<Email>>(
|
body: StreamBuilder<List<Email>>(
|
||||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -68,20 +60,6 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBackButtonBar(BuildContext context) {
|
|
||||||
return BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
tooltip: 'Back',
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmailMessageCard extends ConsumerStatefulWidget {
|
class _EmailMessageCard extends ConsumerStatefulWidget {
|
||||||
@@ -163,17 +141,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
FutureBuilder<EmailBody>(
|
FutureBuilder<EmailBody>(
|
||||||
future: _bodyFuture,
|
future: _bodyFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'Failed to load email: ${snapshot.error}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
.read(undoServiceProvider.notifier)
|
.read(undoServiceProvider.notifier)
|
||||||
.undo(actionId: action.id);
|
.undo(actionId: action.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Action undone.'),
|
content: Text('Action undone.'),
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/di.dart';
|
|
||||||
|
|
||||||
class UserPreferencesScreen extends ConsumerWidget {
|
|
||||||
const UserPreferencesScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Preferences')),
|
|
||||||
body: prefsAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, __) =>
|
|
||||||
const Center(child: Text('Error loading preferences')),
|
|
||||||
data: (prefs) => ListView(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Menu bar position',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Where the folder navigation menu is shown in the mailbox view.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RadioGroup<MenuPosition>(
|
|
||||||
groupValue: prefs.menuPosition,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.updateMenuPosition(value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Bottom (default)'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Open folder navigation from a button at the bottom of the screen.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Top'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Open folder navigation from the hamburger icon in the top bar.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.top,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Single mail view button position',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Where the back button is shown in the single mail view.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RadioGroup<MenuPosition>(
|
|
||||||
groupValue: prefs.mailViewButtonPosition,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.updateMailViewButtonPosition(value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Bottom (default)'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Show the back button at the bottom of the screen.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Top'),
|
|
||||||
subtitle: Text('Show the back button in the top bar.'),
|
|
||||||
value: MenuPosition.top,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
'After mail action',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'What to show after deleting, archiving, or otherwise handling a message.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RadioGroup<AfterMailViewAction>(
|
|
||||||
groupValue: prefs.afterMailViewAction,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.updateAfterMailViewAction(value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<AfterMailViewAction>(
|
|
||||||
title: Text('Next message (default)'),
|
|
||||||
subtitle: Text('Show the next message in the mailbox.'),
|
|
||||||
value: AfterMailViewAction.nextMessage,
|
|
||||||
),
|
|
||||||
RadioListTile<AfterMailViewAction>(
|
|
||||||
title: Text('Return to mailbox'),
|
|
||||||
subtitle: Text('Return to the message list.'),
|
|
||||||
value: AfterMailViewAction.showMailbox,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
|
||||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
|
||||||
|
|
||||||
const _gitHash = String.fromEnvironment('GIT_HASH');
|
|
||||||
|
|
||||||
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
|
||||||
String buildAboutMarkdown({
|
|
||||||
required BuildContext context,
|
|
||||||
PackageInfo? pkg,
|
|
||||||
required int imapCount,
|
|
||||||
required int jmapCount,
|
|
||||||
String? deviceModel,
|
|
||||||
}) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
|
||||||
final physW = (size.width * pixelRatio).toInt();
|
|
||||||
final physH = (size.height * pixelRatio).toInt();
|
|
||||||
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
|
||||||
final versionDisplay = _gitHash.isNotEmpty
|
|
||||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
|
||||||
: version;
|
|
||||||
final osName = _capitalize(Platform.operatingSystem);
|
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
|
||||||
final locale = Localizations.localeOf(context).toString();
|
|
||||||
final textScale = MediaQuery.of(
|
|
||||||
context,
|
|
||||||
).textScaler.scale(1.0).toStringAsFixed(1);
|
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
|
||||||
: '';
|
|
||||||
final deviceModelLine =
|
|
||||||
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
|
||||||
|
|
||||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
|
||||||
'| Property | Value |\n'
|
|
||||||
'|----------|-------|\n'
|
|
||||||
'| App Version | $versionDisplay |\n'
|
|
||||||
'$gitCommitLine'
|
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
|
||||||
'$deviceModelLine'
|
|
||||||
'| Resolution | ${physW}x$physH px'
|
|
||||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
|
||||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
|
||||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
|
||||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
|
||||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
|
||||||
'| Locale | $locale |\n'
|
|
||||||
'| Text Scale | $textScale× |\n'
|
|
||||||
'| DB Schema Version | $dbSchemaVersion |\n'
|
|
||||||
'| IMAP Accounts | $imapCount |\n'
|
|
||||||
'| JMAP Accounts | $jmapCount |\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches device model string, or null when unavailable.
|
|
||||||
Future<String?> getDeviceModel() async {
|
|
||||||
try {
|
|
||||||
final info = DeviceInfoPlugin();
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
final android = await info.androidInfo;
|
|
||||||
return '${android.manufacturer} / ${android.model}';
|
|
||||||
} else if (Platform.isIOS) {
|
|
||||||
final ios = await info.iosInfo;
|
|
||||||
return ios.utsname.machine;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _capitalize(String s) =>
|
|
||||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
|
||||||
@@ -31,13 +31,10 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
|||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
|
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||||
img { max-width: 100%; height: auto; }
|
img { max-width: 100%; height: auto; }
|
||||||
a { color: #1976D2; }
|
a { color: #1976D2; }
|
||||||
* { box-sizing: border-box; max-width: 100%; }
|
* { box-sizing: border-box; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
td, th { overflow-wrap: break-word; word-break: break-word; }
|
|
||||||
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -111,16 +108,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _measureHeight(String _) async {
|
Future<void> _measureHeight(String _) async {
|
||||||
try {
|
final result = await _controller!.runJavaScriptReturningResult(
|
||||||
final result = await _controller!.runJavaScriptReturningResult(
|
'document.documentElement.scrollHeight',
|
||||||
'document.documentElement.scrollHeight',
|
);
|
||||||
);
|
final h = double.tryParse(result.toString());
|
||||||
final h = double.tryParse(result.toString());
|
if (h != null && h > 0 && mounted) {
|
||||||
if (h != null && h > 0 && mounted) {
|
setState(() => _height = h);
|
||||||
setState(() => _height = h);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// WebView not ready yet; height stays at default
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +184,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final launched = await launchUrl(
|
final launched =
|
||||||
uri,
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && mounted) {
|
if (!launched && mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Could not open: $url')),
|
||||||
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-35
@@ -249,22 +249,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
device_info_plus:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: device_info_plus
|
|
||||||
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "13.1.0"
|
|
||||||
device_info_plus_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: device_info_plus_platform_interface
|
|
||||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "8.1.0"
|
|
||||||
drift:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -659,10 +643,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1088,26 +1072,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.31.0"
|
version: "1.30.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.17"
|
version: "0.6.16"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1133,13 +1117,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.2"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.24"
|
version: "6.3.30"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1300,14 +1284,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.3.0"
|
||||||
win32_registry:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: win32_registry
|
|
||||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.3"
|
|
||||||
workmanager:
|
workmanager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-6
@@ -33,7 +33,7 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
||||||
# Date formatting
|
# Date formatting
|
||||||
intl: ^0.20.2
|
intl: any
|
||||||
|
|
||||||
# File picking (compose attachments) and opening downloaded attachments
|
# File picking (compose attachments) and opening downloaded attachments
|
||||||
file_picker: ^12.0.0-beta.4
|
file_picker: ^12.0.0-beta.4
|
||||||
@@ -61,7 +61,6 @@ dependencies:
|
|||||||
# App version metadata for crash reports
|
# App version metadata for crash reports
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
device_info_plus: ^13.1.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -90,7 +89,3 @@ dependency_overrides:
|
|||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||||
# stable Pigeon and is known to work reliably.
|
# stable Pigeon and is known to work reliably.
|
||||||
path_provider_android: ">=2.2.0 <2.2.21"
|
path_provider_android: ">=2.2.0 <2.2.21"
|
||||||
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
|
|
||||||
# channel-error on launchUrl on some Android devices (same root cause as
|
|
||||||
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
|
|
||||||
url_launcher_android: ">=6.3.0 <6.3.25"
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"config:recommended"
|
|
||||||
],
|
|
||||||
"labels": ["dependencies"],
|
|
||||||
"github-actions": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
|
||||||
"addLabels": ["automerge"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Executable
+849
@@ -0,0 +1,849 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agent_loop.py — called from cron every 10 minutes.
|
||||||
|
|
||||||
|
Flow
|
||||||
|
----
|
||||||
|
1. Agent already running?
|
||||||
|
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||||
|
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||||
|
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||||
|
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||||
|
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||||
|
c. Main CI running → save pending-ci state, exit 0
|
||||||
|
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||||
|
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||||
|
section 2a always returns first)
|
||||||
|
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||||
|
save state, exit 0
|
||||||
|
g. No Ready issues → print "nothing to do", exit 0
|
||||||
|
|
||||||
|
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||||
|
|
||||||
|
State file: ~/.sharedinbox-agent-state.json
|
||||||
|
{ "pid": 12345, "issue": 91,
|
||||||
|
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||||
|
|
||||||
|
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||||
|
To resume the Claude conversation, look up the session UUID first:
|
||||||
|
|
||||||
|
scripts/agent_loop.py list # shows NAME and UUID columns
|
||||||
|
claude --resume <uuid> # use the UUID, NOT the session name
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||||
|
os.environ["PATH"] = (
|
||||||
|
f"{Path.home()}/.nix-profile/bin"
|
||||||
|
f":{Path.home()}/go/bin"
|
||||||
|
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── configuration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
REPO = "guettli/sharedinbox"
|
||||||
|
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||||
|
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||||
|
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
|
||||||
|
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||||
|
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
|
||||||
|
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||||
|
"-" + str(Path.home())[1:].replace("/", "-")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Labels used by the workflow.
|
||||||
|
LABEL_READY = "State/Ready"
|
||||||
|
LABEL_IN_PROGRESS = "State/InProgress"
|
||||||
|
LABEL_QUESTION = "State/Question"
|
||||||
|
LABEL_PRIO_HIGH = "Prio/High"
|
||||||
|
|
||||||
|
# Only pick up issues filed by these accounts.
|
||||||
|
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_url(number: int) -> str:
|
||||||
|
return f"{REPO_URL}/issues/{number}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ci_run_url(run_id: int) -> str:
|
||||||
|
return f"{REPO_URL}/actions/runs/{run_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fgj(*args: str) -> None:
|
||||||
|
"""Run a fgj command, raising on failure."""
|
||||||
|
cmd = ["fgj", "--hostname", "codeberg.org", *args]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"fgj {' '.join(args)} failed:\n{result.stderr or result.stdout}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tea_get(path: str) -> dict | list | None:
|
||||||
|
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
|
||||||
|
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
|
||||||
|
cmd = ["tea", "api", path]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"tea api {path} failed:\n{result.stderr or result.stdout}"
|
||||||
|
)
|
||||||
|
out = result.stdout.strip()
|
||||||
|
if not out:
|
||||||
|
return None
|
||||||
|
data = json.loads(out)
|
||||||
|
if isinstance(data, dict) and "message" in data and "url" in data:
|
||||||
|
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||||
|
"""Add/remove labels on an issue via fgj."""
|
||||||
|
cmd = ["issue", "edit", str(issue), "--repo", REPO]
|
||||||
|
for label in add:
|
||||||
|
cmd += ["--add-label", label]
|
||||||
|
for label in remove:
|
||||||
|
cmd += ["--remove-label", label]
|
||||||
|
_fgj(*cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_issue(issue: int) -> None:
|
||||||
|
_fgj("issue", "close", str(issue), "--repo", REPO)
|
||||||
|
_set_labels(issue, add=[], remove=[LABEL_IN_PROGRESS])
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_issue(issue: int, body: str) -> None:
|
||||||
|
_fgj("issue", "comment", str(issue), "--repo", REPO, "--body", body)
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_issues() -> list[dict]:
|
||||||
|
"""Return open issues with State/Ready, Prio/High first, then oldest."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "issue", "list",
|
||||||
|
"--repo", REPO, "--state", "open", "--json"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||||
|
ready = [
|
||||||
|
i for i in data
|
||||||
|
if any(lbl["name"] == LABEL_READY for lbl in i.get("labels", []))
|
||||||
|
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
||||||
|
]
|
||||||
|
ready.sort(key=lambda i: (
|
||||||
|
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
||||||
|
i["number"],
|
||||||
|
))
|
||||||
|
return ready
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_main_ci_run() -> dict | None:
|
||||||
|
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
|
||||||
|
|
||||||
|
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||||
|
on a PR branch could mask the true state of main. We filter to push
|
||||||
|
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||||
|
"""
|
||||||
|
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||||
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
|
for run in runs:
|
||||||
|
if run.get("event") == "push" and run.get("prettyref") == "main":
|
||||||
|
return run
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||||
|
"""Return the latest CI run for a specific branch, or None.
|
||||||
|
|
||||||
|
Forgejo's workflow_runs API has no top-level head_branch field.
|
||||||
|
For push events the branch is in ``prettyref``; for pull_request
|
||||||
|
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
|
||||||
|
"""
|
||||||
|
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||||
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
|
for run in runs:
|
||||||
|
if run.get("event") == "pull_request":
|
||||||
|
try:
|
||||||
|
payload = json.loads(run.get("event_payload", "{}"))
|
||||||
|
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
|
||||||
|
return run
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
elif run.get("event") == "push":
|
||||||
|
if run.get("prettyref") == branch:
|
||||||
|
return run
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
|
||||||
|
"""Return the first PR in the given state whose head branch matches, or None."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||||
|
"--repo", REPO, "--state", state, "--json"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
prs = json.loads(result.stdout)
|
||||||
|
for pr in prs:
|
||||||
|
head = pr.get("head", {})
|
||||||
|
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
if ref == branch:
|
||||||
|
return pr
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _open_issue_prs() -> list[dict]:
|
||||||
|
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||||
|
"--repo", REPO, "--state", "open", "--json"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return []
|
||||||
|
prs = json.loads(result.stdout)
|
||||||
|
issue_prs = []
|
||||||
|
for pr in prs:
|
||||||
|
head = pr.get("head", {})
|
||||||
|
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
if re.match(r"^issue-\d+-fix$", ref or ""):
|
||||||
|
issue_prs.append(pr)
|
||||||
|
issue_prs.sort(key=lambda p: p["number"])
|
||||||
|
return issue_prs
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||||
|
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||||
|
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||||
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
|
for run in runs:
|
||||||
|
try:
|
||||||
|
payload = json.loads(run.get("event_payload", "{}"))
|
||||||
|
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||||
|
return run
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_pr(pr_number: int) -> None:
|
||||||
|
"""Squash-merge a PR via fgj."""
|
||||||
|
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||||
|
|
||||||
|
|
||||||
|
# ── state file ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _read_state() -> dict | None:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
|
||||||
|
data: dict = {
|
||||||
|
"pid": pid,
|
||||||
|
"issue": issue,
|
||||||
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"type": kind,
|
||||||
|
}
|
||||||
|
if issue_title is not None:
|
||||||
|
data["issue_title"] = issue_title
|
||||||
|
if session_name is not None:
|
||||||
|
data["session_name"] = session_name
|
||||||
|
if ci_run_id is not None:
|
||||||
|
data["ci_run_id_at_start"] = ci_run_id
|
||||||
|
STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
STATE_FILE.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_state() -> None:
|
||||||
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_heartbeat() -> None:
|
||||||
|
"""Record that the agent loop ran right now."""
|
||||||
|
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
|
||||||
|
HEARTBEAT_FILE.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_session_uuid(session_name: str) -> str | None:
|
||||||
|
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||||
|
|
||||||
|
Claude stores session metadata in JSONL files; the first entry with
|
||||||
|
type=="agent-name" contains both the human-readable name and the UUID
|
||||||
|
needed for ``claude --resume <uuid>``.
|
||||||
|
"""
|
||||||
|
if not CLAUDE_PROJECTS_DIR.exists():
|
||||||
|
return None
|
||||||
|
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||||
|
try:
|
||||||
|
with jsonl.open() as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
d = json.loads(line)
|
||||||
|
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
|
||||||
|
return d.get("sessionId")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _start_agent(prompt: str, session_name: str) -> int:
|
||||||
|
"""Start Claude Code as a detached background process and return its PID."""
|
||||||
|
log_dir = Path.home() / ".sharedinbox-agent-logs"
|
||||||
|
log_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
|
||||||
|
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
log_file = log_dir / f"{session_name}-{ts}.log"
|
||||||
|
|
||||||
|
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"claude",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--name", session_name,
|
||||||
|
"-p", prompt,
|
||||||
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=log_fh,
|
||||||
|
stderr=log_fh,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
log_fh.close() # Parent closes its copy; the child retains the fd.
|
||||||
|
# Answer the workspace-trust dialog; after this the pipe hits EOF.
|
||||||
|
proc.stdin.write(b"\n")
|
||||||
|
proc.stdin.close()
|
||||||
|
|
||||||
|
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||||
|
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_alive(state: dict) -> bool:
|
||||||
|
"""Return True if the agent process is still running."""
|
||||||
|
pid = state.get("pid")
|
||||||
|
if pid is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_claude_process(pid: int) -> bool:
|
||||||
|
"""Return True if pid's comm name indicates it is a claude/node process."""
|
||||||
|
try:
|
||||||
|
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||||
|
return comm in ("claude", "node")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_age_seconds(state: dict) -> float:
|
||||||
|
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||||
|
try:
|
||||||
|
started_at = datetime.fromisoformat(state["started_at"])
|
||||||
|
return (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _git_summary() -> str:
|
||||||
|
"""Return a one-line summary of the latest commit and whether it's been pushed."""
|
||||||
|
try:
|
||||||
|
commit = subprocess.run(
|
||||||
|
["git", "log", "--oneline", "-1"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
ahead = subprocess.run(
|
||||||
|
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
|
||||||
|
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
|
||||||
|
elif ahead.returncode == 0:
|
||||||
|
push_status = "pushed"
|
||||||
|
else:
|
||||||
|
push_status = "no upstream"
|
||||||
|
return f"{commit} [{push_status}]"
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_agent(state: dict) -> None:
|
||||||
|
"""Forcefully stop the running agent."""
|
||||||
|
pid = state.get("pid")
|
||||||
|
if pid and _is_claude_process(pid):
|
||||||
|
try:
|
||||||
|
os.kill(pid, 9)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
elif pid:
|
||||||
|
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
|
||||||
|
|
||||||
|
|
||||||
|
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list() -> int:
|
||||||
|
"""List recent agent-loop sessions, newest first."""
|
||||||
|
if not CLAUDE_PROJECTS_DIR.exists():
|
||||||
|
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||||
|
agent_name = None
|
||||||
|
session_id = None
|
||||||
|
try:
|
||||||
|
with jsonl.open() as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
d = json.loads(line)
|
||||||
|
if d.get("type") == "agent-name":
|
||||||
|
agent_name = d.get("agentName")
|
||||||
|
session_id = d.get("sessionId")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if agent_name:
|
||||||
|
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
|
||||||
|
|
||||||
|
if not sessions:
|
||||||
|
print("No agent sessions found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sessions.sort(reverse=True)
|
||||||
|
total = len(sessions)
|
||||||
|
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
|
||||||
|
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
||||||
|
for mtime, name, sid in sessions[:20]:
|
||||||
|
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
|
print(f" {ts:<16} {name:<20} {sid}")
|
||||||
|
if total > 20:
|
||||||
|
print(f" ... ({total - 20} more)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── monitor subcommand ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_monitor() -> int:
|
||||||
|
"""Check that the agent loop has run within the last 2 hours.
|
||||||
|
|
||||||
|
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
|
||||||
|
Intended to be called from a scheduled CI job or cron every 2 hours.
|
||||||
|
"""
|
||||||
|
if not HEARTBEAT_FILE.exists():
|
||||||
|
print(
|
||||||
|
f"WARNING: Agent loop heartbeat file missing — "
|
||||||
|
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
|
||||||
|
except ValueError:
|
||||||
|
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
|
||||||
|
return 1
|
||||||
|
age = (datetime.now(timezone.utc) - last_run).total_seconds()
|
||||||
|
if age > MAX_HEARTBEAT_AGE_SECONDS:
|
||||||
|
print(
|
||||||
|
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
|
||||||
|
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _run_loop() -> int:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||||
|
_update_heartbeat()
|
||||||
|
|
||||||
|
state = _read_state()
|
||||||
|
|
||||||
|
# ── 1. Agent already running? ─────────────────────────────────────────────
|
||||||
|
if state and _agent_alive(state):
|
||||||
|
age = _agent_age_seconds(state)
|
||||||
|
issue = state.get("issue")
|
||||||
|
kind = state.get("type", "issue")
|
||||||
|
pid = state.get("pid", "?")
|
||||||
|
|
||||||
|
issue_title = state.get("issue_title", "")
|
||||||
|
issue_ref = (
|
||||||
|
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
|
||||||
|
)
|
||||||
|
|
||||||
|
if age > MAX_AGENT_AGE_SECONDS:
|
||||||
|
print(
|
||||||
|
f"Agent pid={pid!r} ({issue_ref}) "
|
||||||
|
f"has been running for {age/60:.0f} min — aborting."
|
||||||
|
)
|
||||||
|
_kill_agent(state)
|
||||||
|
_clear_state()
|
||||||
|
if issue:
|
||||||
|
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
issue,
|
||||||
|
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
|
||||||
|
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
|
||||||
|
"Please investigate and resume manually.",
|
||||||
|
)
|
||||||
|
print(f"Set {_issue_url(issue)} to State/Question.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session_name = state.get("session_name")
|
||||||
|
uuid = _find_session_uuid(session_name) if session_name else None
|
||||||
|
if uuid:
|
||||||
|
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||||
|
elif session_name:
|
||||||
|
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
||||||
|
else:
|
||||||
|
resume_cmd = ""
|
||||||
|
git_info = _git_summary()
|
||||||
|
parts = [
|
||||||
|
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||||
|
]
|
||||||
|
if resume_cmd:
|
||||||
|
parts.append(f" Resume: {resume_cmd}")
|
||||||
|
if git_info:
|
||||||
|
parts.append(f" Commit: {git_info}")
|
||||||
|
print("\n".join(parts))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||||
|
pending_issue: int | None = None
|
||||||
|
ci_run_id_at_start: int | None = None
|
||||||
|
if state:
|
||||||
|
pending_issue = state.get("issue")
|
||||||
|
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||||
|
_clear_state()
|
||||||
|
|
||||||
|
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
||||||
|
if pending_issue:
|
||||||
|
branch = f"issue-{pending_issue}-fix"
|
||||||
|
pr = _find_pr_for_branch(branch)
|
||||||
|
if pr:
|
||||||
|
pr_number = pr["number"]
|
||||||
|
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||||
|
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
|
||||||
|
pr_run = _latest_ci_run_for_branch(branch)
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") == "running":
|
||||||
|
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
|
||||||
|
_write_state(None, pending_issue, "pending-ci")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||||
|
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
|
||||||
|
prompt = (
|
||||||
|
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
|
||||||
|
f"(PR #{pr_number}). "
|
||||||
|
f"CI run: {_ci_run_url(pr_run['id'])}. "
|
||||||
|
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||||
|
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||||
|
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||||
|
"Do NOT reference any issue numbers in commit messages "
|
||||||
|
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
|
||||||
|
"issue via a commit message would be a bug. "
|
||||||
|
"Verify locally with 'task check' before pushing. "
|
||||||
|
"When done, stop."
|
||||||
|
)
|
||||||
|
session_name = f"ci-fix-pr-{pr_number}"
|
||||||
|
pid = _start_agent(prompt, session_name)
|
||||||
|
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not pr_run:
|
||||||
|
# No CI run yet — might be that CI hasn't triggered yet.
|
||||||
|
# Wait up to 15 min before giving up.
|
||||||
|
pr_created_at = pr.get("created_at", "")
|
||||||
|
try:
|
||||||
|
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
|
||||||
|
age_s = (datetime.now(timezone.utc) - created).total_seconds()
|
||||||
|
except Exception:
|
||||||
|
age_s = 999999
|
||||||
|
if age_s < 900:
|
||||||
|
print(
|
||||||
|
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
|
||||||
|
)
|
||||||
|
_write_state(None, pending_issue, "pending-ci")
|
||||||
|
return 0
|
||||||
|
print(
|
||||||
|
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
|
||||||
|
"agent may not have pushed. Setting to State/Question."
|
||||||
|
)
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
|
||||||
|
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
|
||||||
|
"Please investigate and resume manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# CI passed on the PR branch — squash-merge and close.
|
||||||
|
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
||||||
|
try:
|
||||||
|
_merge_pr(pr_number)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
if _find_pr_for_branch(branch):
|
||||||
|
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
|
||||||
|
"merge command). Please merge manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
_close_issue(pending_issue)
|
||||||
|
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# No open PR — check if it was already merged.
|
||||||
|
merged_pr = _find_pr_for_branch(branch, state="closed")
|
||||||
|
if merged_pr and merged_pr.get("merged"):
|
||||||
|
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
|
||||||
|
_close_issue(pending_issue)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# No open or merged PR — the agent may not have created one, or it was
|
||||||
|
# closed without merging (the bug this block was added to catch).
|
||||||
|
print(
|
||||||
|
f"No open or merged PR found for branch {branch!r} "
|
||||||
|
f"(issue #{pending_issue}) — setting to State/Question."
|
||||||
|
)
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
|
||||||
|
"Please investigate and resume manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
|
||||||
|
# This handles PRs whose CI has passed but were never merged because the
|
||||||
|
# state file was cleared (loop restart, killed agent, manual intervention).
|
||||||
|
open_prs = _open_issue_prs()
|
||||||
|
for pr in open_prs:
|
||||||
|
pr_number = pr["number"]
|
||||||
|
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||||
|
head = pr.get("head", {})
|
||||||
|
branch = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
m = re.match(r"^issue-(\d+)-fix$", branch or "")
|
||||||
|
issue_num = int(m.group(1)) if m else None
|
||||||
|
pr_run = _latest_ci_run_for_pr(pr_number)
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") == "running":
|
||||||
|
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
|
||||||
|
_write_state(None, issue_num, "pending-ci")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||||
|
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") == "success":
|
||||||
|
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||||
|
try:
|
||||||
|
_merge_pr(pr_number)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
|
||||||
|
continue
|
||||||
|
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||||
|
# (e.g. branch-protection rules not satisfied).
|
||||||
|
if _find_pr_for_branch(branch):
|
||||||
|
print(
|
||||||
|
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||||
|
"— skipping to avoid infinite retry."
|
||||||
|
)
|
||||||
|
if issue_num:
|
||||||
|
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
issue_num,
|
||||||
|
f"Automatic merge of PR #{pr_number} failed (PR is still open "
|
||||||
|
"after the merge command). Please merge manually.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if issue_num:
|
||||||
|
_close_issue(issue_num)
|
||||||
|
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
|
||||||
|
else:
|
||||||
|
print(f"Merged PR #{pr_number}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── 3. Global CI check (main branch only) ────────────────────────────────
|
||||||
|
run = _latest_main_ci_run()
|
||||||
|
|
||||||
|
if run and run.get("status") == "running":
|
||||||
|
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||||
|
if pending_issue:
|
||||||
|
_write_state(None, pending_issue, "pending-ci")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if run and run.get("status") in ("failure", "error"):
|
||||||
|
# Guard: if the same main CI run has been failing since the last ci-fix
|
||||||
|
# agent started, that agent pushed to a branch instead of main. Before
|
||||||
|
# spawning another agent, check whether any CI run is currently in
|
||||||
|
# progress (the branch run) and wait if so.
|
||||||
|
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||||
|
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||||
|
in_flight = [
|
||||||
|
r for r in (check or {}).get("workflow_runs", [])
|
||||||
|
if r.get("status") == "running"
|
||||||
|
]
|
||||||
|
if in_flight:
|
||||||
|
print(
|
||||||
|
f"Main CI still shows the same failed run {run['id']}; "
|
||||||
|
f"{_ci_run_url(in_flight[0]['id'])} is running "
|
||||||
|
"(previous ci-fix pushed to a branch). Waiting."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||||
|
prompt = (
|
||||||
|
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
|
||||||
|
f"The CI run ID is {run['id']}. "
|
||||||
|
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||||
|
"Identify the failure, fix it, commit, and push directly to main. "
|
||||||
|
"Verify locally with 'task check' before pushing. "
|
||||||
|
"Do NOT reference any issue numbers in commit messages "
|
||||||
|
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
|
||||||
|
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
|
||||||
|
"Do NOT close any issues. "
|
||||||
|
"When done, stop."
|
||||||
|
)
|
||||||
|
pid = _start_agent(prompt, "ci-fix")
|
||||||
|
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
|
||||||
|
ci_run_id=run["id"] if run else None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# CI is ok (or no run).
|
||||||
|
if pending_issue:
|
||||||
|
latest_run_id = run["id"] if run else None
|
||||||
|
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
|
||||||
|
# CI run hasn't changed since the agent was launched → agent pushed nothing
|
||||||
|
# (likely crashed or hit a rate limit).
|
||||||
|
print(
|
||||||
|
f"No new CI run since agent started for {_issue_url(pending_issue)} "
|
||||||
|
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
|
||||||
|
)
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
"The agent exited without pushing any changes (no new CI run was triggered). "
|
||||||
|
"This usually means the agent hit a rate limit or crashed at startup. "
|
||||||
|
"The issue has been set to State/Question — please review the agent log and retry.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
_close_issue(pending_issue)
|
||||||
|
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
|
||||||
|
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Find a Ready issue.
|
||||||
|
issues = _ready_issues()
|
||||||
|
if not issues:
|
||||||
|
print("No issues with State/Ready. Nothing to do.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
issue = issues[0]
|
||||||
|
issue_number = issue["number"]
|
||||||
|
issue_title = issue["title"]
|
||||||
|
issue_body = issue.get("body", "")
|
||||||
|
|
||||||
|
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
||||||
|
|
||||||
|
# Mark InProgress before starting so the next cron tick sees it even if
|
||||||
|
# the agent hasn't had time to do so yet.
|
||||||
|
_set_labels(
|
||||||
|
issue_number,
|
||||||
|
add=[LABEL_IN_PROGRESS],
|
||||||
|
remove=[LABEL_READY],
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
|
||||||
|
|
||||||
|
Issue title: {issue_title}
|
||||||
|
|
||||||
|
Issue body:
|
||||||
|
{issue_body}
|
||||||
|
|
||||||
|
Instructions:
|
||||||
|
- Understand the issue thoroughly before writing any code.
|
||||||
|
- Implement the required change, following the existing code style.
|
||||||
|
- Write or update tests as appropriate.
|
||||||
|
- Run 'task check' locally and fix any failures before committing.
|
||||||
|
- Commit with a descriptive message and include (#{issue_number}) in the title,
|
||||||
|
e.g. "feat: description (#{issue_number})".
|
||||||
|
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
|
||||||
|
after CI passes; using those keywords would close it prematurely or wrongly.
|
||||||
|
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||||
|
git checkout -b issue-{issue_number}-fix
|
||||||
|
git push -u origin issue-{issue_number}-fix
|
||||||
|
fgj pr create --title "fix: <short description> (#{issue_number})" \\
|
||||||
|
--head issue-{issue_number}-fix --base main --repo {REPO}
|
||||||
|
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR — the loop handles that after CI passes.
|
||||||
|
- If you hit a blocker you cannot resolve, set the issue label to State/Question
|
||||||
|
and stop (do NOT close the issue).
|
||||||
|
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_name = f"issue-{issue_number}"
|
||||||
|
pid = _start_agent(prompt, session_name)
|
||||||
|
current_run_id = run["id"] if run else None
|
||||||
|
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||||
|
sub = parser.add_subparsers(dest="cmd")
|
||||||
|
sub.add_parser("list", help="List recent agent sessions")
|
||||||
|
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.cmd == "list":
|
||||||
|
return cmd_list()
|
||||||
|
if args.cmd == "monitor":
|
||||||
|
return cmd_monitor()
|
||||||
|
return _run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -11,7 +11,6 @@ const _minCoveragePercent = 80;
|
|||||||
|
|
||||||
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
||||||
const _noCode = {
|
const _noCode = {
|
||||||
'lib/core/db_schema_version.dart',
|
|
||||||
'lib/core/repositories/account_repository.dart',
|
'lib/core/repositories/account_repository.dart',
|
||||||
'lib/core/repositories/draft_repository.dart',
|
'lib/core/repositories/draft_repository.dart',
|
||||||
'lib/core/repositories/email_repository.dart',
|
'lib/core/repositories/email_repository.dart',
|
||||||
@@ -20,9 +19,7 @@ const _noCode = {
|
|||||||
'lib/core/repositories/sync_log_repository.dart',
|
'lib/core/repositories/sync_log_repository.dart',
|
||||||
'lib/core/repositories/undo_repository.dart',
|
'lib/core/repositories/undo_repository.dart',
|
||||||
'lib/core/repositories/search_history_repository.dart',
|
'lib/core/repositories/search_history_repository.dart',
|
||||||
'lib/core/repositories/user_preferences_repository.dart',
|
|
||||||
'lib/core/models/undo_action.dart',
|
'lib/core/models/undo_action.dart',
|
||||||
'lib/core/models/user_preferences.dart',
|
|
||||||
'lib/core/storage/secure_storage.dart',
|
'lib/core/storage/secure_storage.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +57,6 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/try_connection_button.dart',
|
'lib/ui/widgets/try_connection_button.dart',
|
||||||
'lib/ui/widgets/undo_shell.dart',
|
'lib/ui/widgets/undo_shell.dart',
|
||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
'lib/ui/screens/email_action_helpers.dart',
|
|
||||||
'lib/ui/utils/about_markdown.dart',
|
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
'lib/core/sync/background_sync.dart',
|
'lib/core/sync/background_sync.dart',
|
||||||
@@ -75,8 +70,6 @@ const _excluded = {
|
|||||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||||
'lib/data/repositories/undo_repository_impl.dart',
|
'lib/data/repositories/undo_repository_impl.dart',
|
||||||
'lib/data/repositories/search_history_repository_impl.dart',
|
'lib/data/repositories/search_history_repository_impl.dart',
|
||||||
'lib/data/repositories/user_preferences_repository_impl.dart',
|
|
||||||
'lib/ui/screens/user_preferences_screen.dart',
|
|
||||||
'lib/core/services/update_service.dart',
|
'lib/core/services/update_service.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +1,102 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ -z "${SOPS_AGE_KEY:-}" ]; then
|
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||||
echo "Error: SOPS_AGE_KEY must be set."
|
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Decrypting secrets with SOPS..."
|
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||||
export SOPS_AGE_KEY="$SOPS_AGE_KEY"
|
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||||
SECRETS_JSON=$(mktemp)
|
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||||
trap "rm -f $SECRETS_JSON" EXIT
|
if [ "$host" == "$port" ]; then
|
||||||
|
port="8774"
|
||||||
|
fi
|
||||||
|
|
||||||
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
MAX_PROBE_ATTEMPTS=5
|
||||||
|
PROBE_DELAY=30
|
||||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||||
|
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
echo "Found active server on $host:$port"
|
||||||
# can use them without referencing Forgejo secrets directly.
|
break
|
||||||
export_secret() {
|
|
||||||
local name="$1"
|
|
||||||
local value
|
|
||||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
|
||||||
# Use heredoc syntax for multiline-safe export
|
|
||||||
{
|
|
||||||
printf '%s<<__EOF__\n' "$name"
|
|
||||||
printf '%s\n' "$value"
|
|
||||||
printf '__EOF__\n'
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
fi
|
fi
|
||||||
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
|
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||||
}
|
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||||
|
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
||||||
|
sleep $PROBE_DELAY
|
||||||
|
done
|
||||||
|
|
||||||
export_secret "SSH_PRIVATE_KEY"
|
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||||
export_secret "SSH_KNOWN_HOSTS"
|
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||||
export_secret "SSH_USER"
|
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||||
export_secret "SSH_HOST"
|
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||||
export_secret "WEBSITE_SSH_HOST"
|
timeout 8 dagger version >/dev/null 2>&1; then
|
||||||
export_secret "PLAY_STORE_CONFIG_JSON"
|
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||||
export_secret "ANDROID_KEYSTORE_BASE64"
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
export_secret "ANDROID_KEYSTORE_PASSWORD"
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||||
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||||
export_secret "RENOVATE_FORGEJO_TOKEN"
|
else
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||||
# Setup SSH directory and keys
|
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||||
mkdir -p ~/.ssh
|
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||||
chmod 700 ~/.ssh
|
fi
|
||||||
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
exit 0
|
||||||
chmod 600 ~/.ssh/dagger_key
|
|
||||||
|
|
||||||
# Add remote host to known_hosts
|
|
||||||
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
# Create a background SSH tunnel to the Dagger engine.
|
|
||||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
|
||||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
|
||||||
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
|
||||||
|
|
||||||
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
|
||||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
|
||||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
|
|
||||||
fi
|
fi
|
||||||
|
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||||
|
|
||||||
# Verify the connection
|
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||||
echo "Verifying connection to Dagger engine via SSH tunnel..."
|
mkdir -p /tmp/dagger-tls
|
||||||
# Use a simple command that doesn't require complex GraphQL operations.
|
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||||
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
|
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||||
echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
|
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||||
# Debug
|
chmod 600 /tmp/dagger-tls/client.key
|
||||||
ps aux | grep ssh
|
|
||||||
|
# 3. Configure and start stunnel
|
||||||
|
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||||
|
cat << EOF > "$STUNNEL_CONF"
|
||||||
|
client = yes
|
||||||
|
foreground = yes
|
||||||
|
pid = /tmp/stunnel.pid
|
||||||
|
debug = warning
|
||||||
|
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||||
|
socket = r:SO_KEEPALIVE=1
|
||||||
|
socket = r:TCP_KEEPIDLE=10
|
||||||
|
socket = r:TCP_KEEPINTVL=5
|
||||||
|
socket = r:TCP_KEEPCNT=3
|
||||||
|
|
||||||
|
[dagger]
|
||||||
|
accept = 127.0.0.1:1774
|
||||||
|
connect = $host:$port
|
||||||
|
CAfile = /tmp/dagger-tls/ca.crt
|
||||||
|
cert = /tmp/dagger-tls/client.crt
|
||||||
|
key = /tmp/dagger-tls/client.key
|
||||||
|
verifyChain = yes
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start stunnel in the background
|
||||||
|
stunnel "$STUNNEL_CONF" &
|
||||||
|
TUNNEL_PID=$!
|
||||||
|
|
||||||
|
# Give it a moment to establish
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||||
|
echo "Error: stunnel failed to start"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Dagger connection verified successfully."
|
|
||||||
|
# 4. Export environment for subsequent CI steps
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||||
|
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||||
|
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||||
|
else
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,802 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for agent_loop.py."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import agent_loop
|
||||||
|
|
||||||
|
|
||||||
|
class TestUrlHelpers(unittest.TestCase):
|
||||||
|
def test_issue_url(self):
|
||||||
|
url = agent_loop._issue_url(128)
|
||||||
|
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
|
||||||
|
|
||||||
|
def test_ci_run_url(self):
|
||||||
|
url = agent_loop._ci_run_url(4145144)
|
||||||
|
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateFile(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
|
||||||
|
self._tmp.close()
|
||||||
|
self._orig = agent_loop.STATE_FILE
|
||||||
|
agent_loop.STATE_FILE = Path(self._tmp.name)
|
||||||
|
Path(self._tmp.name).unlink() # Start with no state file.
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
agent_loop.STATE_FILE = self._orig
|
||||||
|
Path(self._tmp.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_write_state_stores_pid(self):
|
||||||
|
agent_loop._write_state(12345, 91, "issue")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertEqual(data["pid"], 12345)
|
||||||
|
self.assertNotIn("tmux_session", data)
|
||||||
|
|
||||||
|
def test_write_state_stores_issue_and_kind(self):
|
||||||
|
agent_loop._write_state(99, 7, "ci-fix")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertEqual(data["issue"], 7)
|
||||||
|
self.assertEqual(data["type"], "ci-fix")
|
||||||
|
self.assertIn("started_at", data)
|
||||||
|
|
||||||
|
def test_read_state_returns_none_when_missing(self):
|
||||||
|
self.assertIsNone(agent_loop._read_state())
|
||||||
|
|
||||||
|
def test_read_and_write_roundtrip(self):
|
||||||
|
agent_loop._write_state(42, 10, "issue")
|
||||||
|
state = agent_loop._read_state()
|
||||||
|
self.assertIsNotNone(state)
|
||||||
|
self.assertEqual(state["pid"], 42)
|
||||||
|
self.assertEqual(state["issue"], 10)
|
||||||
|
|
||||||
|
def test_clear_state_removes_file(self):
|
||||||
|
agent_loop._write_state(1, None, "ci-fix")
|
||||||
|
agent_loop._clear_state()
|
||||||
|
self.assertIsNone(agent_loop._read_state())
|
||||||
|
|
||||||
|
def test_write_state_stores_issue_title(self):
|
||||||
|
agent_loop._write_state(42, 10, "issue", "My Test Issue")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertEqual(data["issue_title"], "My Test Issue")
|
||||||
|
|
||||||
|
def test_write_state_omits_issue_title_when_none(self):
|
||||||
|
agent_loop._write_state(42, None, "ci-fix")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertNotIn("issue_title", data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentAlive(unittest.TestCase):
|
||||||
|
def test_own_pid_is_alive(self):
|
||||||
|
self.assertTrue(agent_loop._agent_alive({"pid": os.getpid()}))
|
||||||
|
|
||||||
|
def test_nonexistent_pid_is_dead(self):
|
||||||
|
self.assertFalse(agent_loop._agent_alive({"pid": 999999999}))
|
||||||
|
|
||||||
|
def test_missing_pid_returns_false(self):
|
||||||
|
self.assertFalse(agent_loop._agent_alive({}))
|
||||||
|
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsClaudeProcess(unittest.TestCase):
|
||||||
|
def test_returns_true_for_claude_comm(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
|
||||||
|
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
def test_returns_true_for_node_comm(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
|
||||||
|
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
def test_returns_false_for_other_process(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
|
||||||
|
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
def test_returns_false_when_proc_missing(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
|
||||||
|
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
|
||||||
|
class TestKillAgent(unittest.TestCase):
|
||||||
|
def test_kill_sends_sigkill(self):
|
||||||
|
with patch("agent_loop._is_claude_process", return_value=True):
|
||||||
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
|
agent_loop._kill_agent({"pid": 1234})
|
||||||
|
mock_kill.assert_called_once_with(1234, 9)
|
||||||
|
|
||||||
|
def test_kill_ignores_missing_process(self):
|
||||||
|
with patch("agent_loop._is_claude_process", return_value=True):
|
||||||
|
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||||
|
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||||
|
|
||||||
|
def test_kill_noop_when_no_pid(self):
|
||||||
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
|
agent_loop._kill_agent({})
|
||||||
|
mock_kill.assert_not_called()
|
||||||
|
|
||||||
|
def test_kill_skips_recycled_pid(self):
|
||||||
|
with patch("agent_loop._is_claude_process", return_value=False):
|
||||||
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
|
agent_loop._kill_agent({"pid": 1234})
|
||||||
|
mock_kill.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartAgent(unittest.TestCase):
|
||||||
|
def _make_mock_proc(self, pid=42):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.pid = pid
|
||||||
|
proc.stdin = io.BytesIO()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def test_start_agent_returns_pid(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=42)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc):
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
result = agent_loop._start_agent("do something", "issue-99")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
def test_start_agent_uses_popen_not_tmux(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=7)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
with patch("agent_loop.subprocess.run") as mock_run:
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
agent_loop._start_agent("prompt", "ci-fix")
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
def test_start_agent_passes_session_name_to_claude(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=7)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
agent_loop._start_agent("prompt", "issue-55")
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
self.assertIn("issue-55", cmd)
|
||||||
|
self.assertIn("claude", cmd[0])
|
||||||
|
|
||||||
|
def test_start_agent_uses_start_new_session(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=7)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
agent_loop._start_agent("prompt", "issue-55")
|
||||||
|
kwargs = mock_popen.call_args[1]
|
||||||
|
self.assertTrue(kwargs.get("start_new_session"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain(unittest.TestCase):
|
||||||
|
"""Tests for the main() flow."""
|
||||||
|
|
||||||
|
def _make_mock_proc(self, pid=42):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.pid = pid
|
||||||
|
proc.stdin = io.BytesIO()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def _make_issue(self, number=10, title="Do something"):
|
||||||
|
return {"number": number, "title": title, "body": "", "labels": []}
|
||||||
|
|
||||||
|
def test_sets_in_progress_before_starting_agent(self):
|
||||||
|
"""_set_labels(InProgress) must be called before _start_agent."""
|
||||||
|
call_order = []
|
||||||
|
mock_proc = self._make_mock_proc(pid=55)
|
||||||
|
|
||||||
|
def fake_set_labels(issue, add, remove):
|
||||||
|
call_order.append(("set_labels", add, remove))
|
||||||
|
|
||||||
|
def fake_start_agent(prompt, session_name):
|
||||||
|
call_order.append(("start_agent", session_name))
|
||||||
|
return 55
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||||
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
|
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||||
|
patch("agent_loop._write_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
labels_idx = next(
|
||||||
|
i for i, c in enumerate(call_order) if c[0] == "set_labels"
|
||||||
|
)
|
||||||
|
agent_idx = next(
|
||||||
|
i for i, c in enumerate(call_order) if c[0] == "start_agent"
|
||||||
|
)
|
||||||
|
self.assertLess(labels_idx, agent_idx,
|
||||||
|
"_set_labels must be called before _start_agent")
|
||||||
|
|
||||||
|
def test_sets_in_progress_label_and_removes_ready(self):
|
||||||
|
"""The InProgress label is added and the Ready label is removed."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_set_labels(issue, add, remove):
|
||||||
|
captured["add"] = add
|
||||||
|
captured["remove"] = remove
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||||
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
|
patch("agent_loop._start_agent", return_value=99), \
|
||||||
|
patch("agent_loop._write_state"):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
|
||||||
|
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
|
||||||
|
|
||||||
|
def test_no_ready_issues_does_nothing(self):
|
||||||
|
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
|
patch("agent_loop._start_agent") as mock_start:
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_labels.assert_not_called()
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
|
||||||
|
def test_prompt_does_not_tell_agent_to_close_issue(self):
|
||||||
|
"""Agents must not close issues; the loop handles closing after CI passes."""
|
||||||
|
captured_prompt = {}
|
||||||
|
|
||||||
|
def fake_start_agent(prompt, session_name):
|
||||||
|
captured_prompt["prompt"] = prompt
|
||||||
|
return 77
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||||
|
patch("agent_loop._set_labels"), \
|
||||||
|
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||||
|
patch("agent_loop._write_state"):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
|
||||||
|
prompt = captured_prompt.get("prompt", "")
|
||||||
|
# "do NOT close the issue" (blocker instruction) is fine; what must be
|
||||||
|
# absent is any affirmative instruction to close on completion.
|
||||||
|
self.assertNotIn("close the issue and stop", prompt.lower())
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingCi(unittest.TestCase):
|
||||||
|
"""Tests for the pending-CI state: issue closed only after CI passes."""
|
||||||
|
|
||||||
|
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||||
|
return {
|
||||||
|
"pid": 999999999, # non-existent PID
|
||||||
|
"issue": issue,
|
||||||
|
"started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"type": kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||||
|
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||||
|
|
||||||
|
def _find_pr_open(self, branch, state="open"):
|
||||||
|
if state == "open":
|
||||||
|
return self._open_pr(branch)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||||
|
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||||
|
# First call: PR found open. Second call (post-merge verification): PR closed.
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_merge.assert_called_once_with(5)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
|
def test_ci_passed_output_includes_ci_run_url(self):
|
||||||
|
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
||||||
|
patch("agent_loop._merge_pr"), \
|
||||||
|
patch("agent_loop._close_issue"), \
|
||||||
|
patch("agent_loop._clear_state"), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
|
||||||
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
||||||
|
|
||||||
|
def test_already_merged_pr_closes_issue_without_ci_url(self):
|
||||||
|
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
|
||||||
|
def find_pr(branch, state="open"):
|
||||||
|
if state == "closed":
|
||||||
|
return {"number": 5, "merged": True}
|
||||||
|
return None
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
self.assertIn("already merged", output)
|
||||||
|
self.assertNotIn("/actions/runs/", output)
|
||||||
|
|
||||||
|
def test_no_pr_found_sets_question_label(self):
|
||||||
|
"""When no open or merged PR exists for the pending branch, set State/Question."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||||
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
|
patch("agent_loop._comment_issue") as mock_comment, \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_not_called()
|
||||||
|
mock_labels.assert_called_once_with(
|
||||||
|
10,
|
||||||
|
add=[agent_loop.LABEL_QUESTION],
|
||||||
|
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||||
|
)
|
||||||
|
mock_comment.assert_called_once()
|
||||||
|
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
|
||||||
|
|
||||||
|
def test_does_not_close_issue_when_ci_fails(self):
|
||||||
|
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._start_agent", return_value=55), \
|
||||||
|
patch("agent_loop._write_state"), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_not_called()
|
||||||
|
|
||||||
|
def test_saves_pending_ci_state_while_ci_running(self):
|
||||||
|
"""When CI is still running on PR branch after agent finishes, pending issue is preserved."""
|
||||||
|
written = {}
|
||||||
|
|
||||||
|
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||||
|
written["pid"] = pid
|
||||||
|
written["issue"] = issue
|
||||||
|
written["kind"] = kind
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "running"}), \
|
||||||
|
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(written.get("issue"), 10)
|
||||||
|
self.assertEqual(written.get("kind"), "pending-ci")
|
||||||
|
self.assertIsNone(written.get("pid"))
|
||||||
|
|
||||||
|
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||||
|
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
|
||||||
|
written = {}
|
||||||
|
|
||||||
|
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||||
|
written["pid"] = pid
|
||||||
|
written["issue"] = issue
|
||||||
|
written["kind"] = kind
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
||||||
|
patch("agent_loop._start_agent", return_value=55), \
|
||||||
|
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(written.get("issue"), 10)
|
||||||
|
self.assertEqual(written.get("kind"), "ci-fix")
|
||||||
|
|
||||||
|
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||||
|
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_merge.assert_called_once_with(5)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
|
def test_no_pending_issue_ci_fix_without_issue(self):
|
||||||
|
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
|
||||||
|
with patch("agent_loop._read_state", return_value={
|
||||||
|
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"type": "ci-fix",
|
||||||
|
}), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutputFormat(unittest.TestCase):
|
||||||
|
"""Verify output format: no [agent_loop] prefix, URLs in output."""
|
||||||
|
|
||||||
|
def test_output_starts_with_header(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
first_line = buf.getvalue().splitlines()[0]
|
||||||
|
self.assertTrue(first_line.startswith("---------------------- Starting "),
|
||||||
|
f"Unexpected first line: {first_line!r}")
|
||||||
|
|
||||||
|
def test_no_agent_loop_prefix_in_output(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
self.assertNotIn("[agent_loop]", buf.getvalue())
|
||||||
|
|
||||||
|
def test_ci_run_output_contains_url(self):
|
||||||
|
run = {"id": 4145144, "status": "running"}
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||||
|
buf.getvalue())
|
||||||
|
|
||||||
|
def test_issue_output_contains_url_and_title(self):
|
||||||
|
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||||
|
patch("agent_loop._set_labels"), \
|
||||||
|
patch("agent_loop._start_agent", return_value=99), \
|
||||||
|
patch("agent_loop._write_state"), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
|
||||||
|
self.assertIn("Fix something", output)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLatestMainCiRun(unittest.TestCase):
|
||||||
|
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
|
||||||
|
|
||||||
|
def test_skips_schedule_runs_returns_push_to_main(self):
|
||||||
|
runs = [
|
||||||
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
|
||||||
|
]
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_main_ci_run()
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["id"], 2)
|
||||||
|
|
||||||
|
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||||
|
runs = [
|
||||||
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
]
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_main_ci_run()
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_push_to_main_run(self):
|
||||||
|
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_main_ci_run()
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["id"], 42)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||||
|
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||||
|
|
||||||
|
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
|
||||||
|
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
|
||||||
|
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
|
||||||
|
|
||||||
|
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
|
||||||
|
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
|
||||||
|
|
||||||
|
def _mock_tea_runs(self, runs):
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
def test_pr_event_matches_via_event_payload(self):
|
||||||
|
run = self._make_pr_run("issue-166-fix")
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["id"], 1)
|
||||||
|
|
||||||
|
def test_pr_event_does_not_match_wrong_branch(self):
|
||||||
|
run = self._make_pr_run("issue-99-fix")
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_push_event_matches_via_prettyref(self):
|
||||||
|
run = self._make_push_run("issue-166-fix")
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["id"], 2)
|
||||||
|
|
||||||
|
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
|
||||||
|
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
|
||||||
|
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_head_branch_field_absent_still_works(self):
|
||||||
|
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
|
||||||
|
run = self._make_pr_run("issue-166-fix")
|
||||||
|
self.assertNotIn("head_branch", run)
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
|
||||||
|
def test_returns_none_when_no_runs(self):
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_first_matching_run(self):
|
||||||
|
runs = [
|
||||||
|
self._make_pr_run("issue-166-fix", status="success"),
|
||||||
|
self._make_pr_run("issue-166-fix", status="failure"),
|
||||||
|
]
|
||||||
|
runs[0]["id"] = 10
|
||||||
|
runs[1]["id"] = 11
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||||
|
self.assertEqual(result["id"], 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindSessionUuid(unittest.TestCase):
|
||||||
|
"""Tests for _find_session_uuid()."""
|
||||||
|
|
||||||
|
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
|
||||||
|
path = directory / filename
|
||||||
|
with path.open("w") as fh:
|
||||||
|
for entry in entries:
|
||||||
|
fh.write(json.dumps(entry) + "\n")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_returns_uuid_for_matching_session_name(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
projects_dir = Path(tmpdir)
|
||||||
|
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||||
|
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
|
||||||
|
])
|
||||||
|
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||||
|
try:
|
||||||
|
result = agent_loop._find_session_uuid("issue-91")
|
||||||
|
finally:
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||||
|
self.assertEqual(result, "uuid-abc-123")
|
||||||
|
|
||||||
|
def test_returns_none_when_name_does_not_match(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
projects_dir = Path(tmpdir)
|
||||||
|
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||||
|
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
|
||||||
|
])
|
||||||
|
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||||
|
try:
|
||||||
|
result = agent_loop._find_session_uuid("issue-91")
|
||||||
|
finally:
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_none_when_directory_missing(self):
|
||||||
|
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
|
||||||
|
try:
|
||||||
|
result = agent_loop._find_session_uuid("issue-91")
|
||||||
|
finally:
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_none_when_no_agent_name_entry(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
projects_dir = Path(tmpdir)
|
||||||
|
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||||
|
{"type": "message", "content": "hello"},
|
||||||
|
])
|
||||||
|
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||||
|
try:
|
||||||
|
result = agent_loop._find_session_uuid("issue-91")
|
||||||
|
finally:
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_scans_multiple_files_to_find_match(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
projects_dir = Path(tmpdir)
|
||||||
|
self._write_jsonl(projects_dir, "aaa.jsonl", [
|
||||||
|
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
|
||||||
|
])
|
||||||
|
self._write_jsonl(projects_dir, "bbb.jsonl", [
|
||||||
|
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
|
||||||
|
])
|
||||||
|
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||||
|
try:
|
||||||
|
result = agent_loop._find_session_uuid("issue-91")
|
||||||
|
finally:
|
||||||
|
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||||
|
self.assertEqual(result, "uuid-91")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunLoopResumeCommand(unittest.TestCase):
|
||||||
|
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
|
||||||
|
|
||||||
|
def _alive_state(self, session_name="issue-91"):
|
||||||
|
return {
|
||||||
|
"pid": os.getpid(), # own PID is always alive
|
||||||
|
"issue": 91,
|
||||||
|
"started_at": "2026-05-23T12:00:00+00:00",
|
||||||
|
"type": "issue",
|
||||||
|
"session_name": session_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_resume_shows_uuid_when_found(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||||
|
patch("agent_loop._agent_alive", return_value=True), \
|
||||||
|
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||||
|
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
|
||||||
|
patch("agent_loop._git_summary", return_value=""), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertIn(f"claude --resume {fake_uuid}", output)
|
||||||
|
|
||||||
|
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||||
|
patch("agent_loop._agent_alive", return_value=True), \
|
||||||
|
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||||
|
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||||
|
patch("agent_loop._git_summary", return_value=""), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertIn("scripts/agent_loop.py list", output)
|
||||||
|
# Must NOT show the session name as a valid resume argument.
|
||||||
|
self.assertNotIn("claude --resume issue-91", output)
|
||||||
|
|
||||||
|
def test_resume_not_shown_when_no_session_name(self):
|
||||||
|
state = self._alive_state()
|
||||||
|
del state["session_name"]
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=state), \
|
||||||
|
patch("agent_loop._agent_alive", return_value=True), \
|
||||||
|
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||||
|
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||||
|
patch("agent_loop._git_summary", return_value=""), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertNotIn("Resume:", output)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeat(unittest.TestCase):
|
||||||
|
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
|
||||||
|
self._tmp.close()
|
||||||
|
self._orig = agent_loop.HEARTBEAT_FILE
|
||||||
|
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
|
||||||
|
Path(self._tmp.name).unlink() # Start with no heartbeat file.
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
agent_loop.HEARTBEAT_FILE = self._orig
|
||||||
|
Path(self._tmp.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_update_heartbeat_writes_timestamp(self):
|
||||||
|
agent_loop._update_heartbeat()
|
||||||
|
content = Path(self._tmp.name).read_text().strip()
|
||||||
|
dt = datetime.fromisoformat(content)
|
||||||
|
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||||
|
self.assertLess(age, 5)
|
||||||
|
|
||||||
|
def test_update_heartbeat_creates_file(self):
|
||||||
|
self.assertFalse(Path(self._tmp.name).exists())
|
||||||
|
agent_loop._update_heartbeat()
|
||||||
|
self.assertTrue(Path(self._tmp.name).exists())
|
||||||
|
|
||||||
|
def test_monitor_healthy_when_recent(self):
|
||||||
|
agent_loop._update_heartbeat()
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
|
||||||
|
def test_monitor_warns_when_heartbeat_missing(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertIn("WARNING", buf.getvalue())
|
||||||
|
|
||||||
|
def test_monitor_warns_when_stale(self):
|
||||||
|
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
||||||
|
Path(self._tmp.name).write_text(stale)
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertIn("WARNING", buf.getvalue())
|
||||||
|
|
||||||
|
def test_monitor_warns_when_corrupted(self):
|
||||||
|
Path(self._tmp.name).write_text("not-a-timestamp")
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertIn("WARNING", buf.getvalue())
|
||||||
|
|
||||||
|
def test_run_loop_updates_heartbeat(self):
|
||||||
|
self.assertFalse(Path(self._tmp.name).exists())
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
self.assertTrue(Path(self._tmp.name).exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because one or more lines are too long
@@ -20,67 +20,63 @@ Future<imap.ImapClient> _fakeImapConnect(
|
|||||||
throw const SocketException('fake — no real IMAP server in tests');
|
throw const SocketException('fake — no real IMAP server in tests');
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||||
'AccountSyncManager schedules IMAP sync for multiple accounts',
|
() async {
|
||||||
() async {
|
final accounts = _FakeAccounts('pw');
|
||||||
final accounts = _FakeAccounts('pw');
|
final mailboxes = _FakeMailboxes();
|
||||||
final mailboxes = _FakeMailboxes();
|
final emails = _FakeEmails();
|
||||||
final emails = _FakeEmails();
|
final logs = _FakeLogs();
|
||||||
final logs = _FakeLogs();
|
|
||||||
|
|
||||||
final manager = AccountSyncManager(
|
final manager = AccountSyncManager(
|
||||||
accounts,
|
accounts,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
emails,
|
emails,
|
||||||
syncLog: logs,
|
syncLog: logs,
|
||||||
imapConnect: _fakeImapConnect,
|
imapConnect: _fakeImapConnect,
|
||||||
);
|
);
|
||||||
|
|
||||||
final a1 = _account('1');
|
final a1 = _account('1');
|
||||||
final a2 = _account('2');
|
final a2 = _account('2');
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
accounts.push([a1, a2]);
|
accounts.push([a1, a2]);
|
||||||
|
|
||||||
// Allow some time for listeners to fire.
|
// Allow some time for listeners to fire.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
manager.dispose();
|
manager.dispose();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||||
'AccountSyncManager schedules JMAP sync for multiple accounts',
|
() async {
|
||||||
() async {
|
final accounts = _FakeAccounts('pw');
|
||||||
final accounts = _FakeAccounts('pw');
|
final mailboxes = _FakeMailboxes();
|
||||||
final mailboxes = _FakeMailboxes();
|
final emails = _FakeEmails();
|
||||||
final emails = _FakeEmails();
|
final logs = _FakeLogs();
|
||||||
final logs = _FakeLogs();
|
|
||||||
|
|
||||||
final manager = AccountSyncManager(
|
final manager = AccountSyncManager(
|
||||||
accounts,
|
accounts,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
emails,
|
emails,
|
||||||
syncLog: logs,
|
syncLog: logs,
|
||||||
);
|
);
|
||||||
|
|
||||||
final a1 = _jmapAccount('1');
|
final a1 = _jmapAccount('1');
|
||||||
final a2 = _jmapAccount('2');
|
final a2 = _jmapAccount('2');
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
accounts.push([a1, a2]);
|
accounts.push([a1, a2]);
|
||||||
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
manager.dispose();
|
manager.dispose();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Account _account(String id) => Account(
|
Account _account(String id) => Account(
|
||||||
@@ -153,29 +149,17 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmails implements EmailRepository {
|
class _FakeEmails implements EmailRepository {
|
||||||
final syncCounts = <String, int>{};
|
final syncCounts = <String, int>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
Stream<List<Email>> observeEmails(
|
||||||
|
String a,
|
||||||
|
String m, {
|
||||||
|
int limit = 50,
|
||||||
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -304,8 +288,6 @@ class _FakeLogs implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? stackTrace,
|
|
||||||
bool isPermanent = false,
|
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -566,61 +566,59 @@ void main() {
|
|||||||
expect(pending.first.changeType, 'delete');
|
expect(pending.first.changeType, 'delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('downloadAttachment fetches binary attachment bytes from IMAP',
|
||||||
'downloadAttachment fetches binary attachment bytes from IMAP',
|
() async {
|
||||||
() async {
|
final attachmentBytes = Uint8List.fromList(
|
||||||
final attachmentBytes = Uint8List.fromList(
|
List.generate(32, (i) => i + 1),
|
||||||
List.generate(32, (i) => i + 1),
|
);
|
||||||
|
const attachmentName = 'hello.bin';
|
||||||
|
const attachmentMime = 'application/octet-stream';
|
||||||
|
|
||||||
|
// Build a multipart email with a binary attachment and append it.
|
||||||
|
final client = await _imapConnect(
|
||||||
|
host: imapHost,
|
||||||
|
port: imapPort,
|
||||||
|
user: userEmail,
|
||||||
|
pass: userPass,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final builder = MessageBuilder()
|
||||||
|
..from = [MailAddress('Alice', userEmail)]
|
||||||
|
..to = [MailAddress('Alice', userEmail)]
|
||||||
|
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||||
|
..text = 'See attachment.';
|
||||||
|
builder.addBinary(
|
||||||
|
attachmentBytes,
|
||||||
|
MediaType.fromText(attachmentMime),
|
||||||
|
filename: attachmentName,
|
||||||
);
|
);
|
||||||
const attachmentName = 'hello.bin';
|
await client.appendMessage(
|
||||||
const attachmentMime = 'application/octet-stream';
|
builder.buildMimeMessage(),
|
||||||
|
targetMailboxPath: 'INBOX',
|
||||||
// Build a multipart email with a binary attachment and append it.
|
|
||||||
final client = await _imapConnect(
|
|
||||||
host: imapHost,
|
|
||||||
port: imapPort,
|
|
||||||
user: userEmail,
|
|
||||||
pass: userPass,
|
|
||||||
);
|
);
|
||||||
try {
|
} finally {
|
||||||
final builder = MessageBuilder()
|
await client.logout();
|
||||||
..from = [MailAddress('Alice', userEmail)]
|
}
|
||||||
..to = [MailAddress('Alice', userEmail)]
|
|
||||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
|
||||||
..text = 'See attachment.';
|
|
||||||
builder.addBinary(
|
|
||||||
attachmentBytes,
|
|
||||||
MediaType.fromText(attachmentMime),
|
|
||||||
filename: attachmentName,
|
|
||||||
);
|
|
||||||
await client.appendMessage(
|
|
||||||
builder.buildMimeMessage(),
|
|
||||||
targetMailboxPath: 'INBOX',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
expect(emails, hasLength(1));
|
expect(emails, hasLength(1));
|
||||||
expect(emails.first.hasAttachment, isTrue);
|
expect(emails.first.hasAttachment, isTrue);
|
||||||
|
|
||||||
final body = await r.emails.getEmailBody(emails.first.id);
|
final body = await r.emails.getEmailBody(emails.first.id);
|
||||||
expect(body.attachments, hasLength(1));
|
expect(body.attachments, hasLength(1));
|
||||||
expect(body.attachments.first.filename, attachmentName);
|
expect(body.attachments.first.filename, attachmentName);
|
||||||
expect(body.attachments.first.contentType, attachmentMime);
|
expect(body.attachments.first.contentType, attachmentMime);
|
||||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||||
|
|
||||||
final path = await r.emails.downloadAttachment(
|
final path = await r.emails.downloadAttachment(
|
||||||
emails.first.id,
|
emails.first.id,
|
||||||
body.attachments.first,
|
body.attachments.first,
|
||||||
);
|
);
|
||||||
final downloaded = await File(path).readAsBytes();
|
final downloaded = await File(path).readAsBytes();
|
||||||
expect(downloaded, equals(attachmentBytes));
|
expect(downloaded, equals(attachmentBytes));
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,15 +73,13 @@ abstract class AccountRepositoryContract {
|
|||||||
expect(await repo.getPassword(_a.id), 'new');
|
expect(await repo.getPassword(_a.id), 'new');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('removeAccount makes account disappear from observeAccounts',
|
||||||
'removeAccount makes account disappear from observeAccounts',
|
() async {
|
||||||
() async {
|
final repo = makeRepo();
|
||||||
final repo = makeRepo();
|
await repo.addAccount(_a, 'pw');
|
||||||
await repo.addAccount(_a, 'pw');
|
await repo.removeAccount(_a.id);
|
||||||
await repo.removeAccount(_a.id);
|
expect(await repo.observeAccounts().first, isEmpty);
|
||||||
expect(await repo.observeAccounts().first, isEmpty);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('getAccount returns null after removeAccount', () async {
|
test('getAccount returns null after removeAccount', () async {
|
||||||
final repo = makeRepo();
|
final repo = makeRepo();
|
||||||
|
|||||||
@@ -37,41 +37,44 @@ void main() {
|
|||||||
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
||||||
// loop must stop permanently instead of retrying indefinitely with backoff.
|
// loop must stop permanently instead of retrying indefinitely with backoff.
|
||||||
test(
|
test(
|
||||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||||
() async {
|
() async {
|
||||||
final syncLog = FakeSyncLogRepository();
|
final syncLog = FakeSyncLogRepository();
|
||||||
|
|
||||||
final m = AccountSyncManager(
|
final m = AccountSyncManager(
|
||||||
_AccountRepositoryWithMissingPlugin(),
|
_AccountRepositoryWithMissingPlugin(),
|
||||||
FakeMailboxRepositoryWithInbox(),
|
FakeMailboxRepositoryWithInbox(),
|
||||||
FakeEmailRepository(),
|
FakeEmailRepository(),
|
||||||
syncLog: syncLog,
|
syncLog: syncLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
m.start();
|
m.start();
|
||||||
|
|
||||||
// Allow the first sync cycle to run and fail.
|
// Allow the first sync cycle to run and fail.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(syncLog.logs, hasLength(1));
|
expect(syncLog.logs, hasLength(1));
|
||||||
expect(syncLog.logs.first.success, isFalse);
|
expect(syncLog.logs.first.success, isFalse);
|
||||||
|
|
||||||
// Kicking the loop should have no effect once it has stopped permanently.
|
// Kicking the loop should have no effect once it has stopped permanently.
|
||||||
m.syncNow('1');
|
m.syncNow('1');
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
// Before the fix: kick triggers a retry → 2 log entries.
|
// Before the fix: kick triggers a retry → 2 log entries.
|
||||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||||
expect(syncLog.logs, hasLength(1));
|
expect(syncLog.logs, hasLength(1));
|
||||||
|
|
||||||
m.dispose();
|
m.dispose();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeEmailRepository implements EmailRepository {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
Stream<List<Email>> observeEmails(
|
||||||
|
String a,
|
||||||
|
String m, {
|
||||||
|
int limit = 50,
|
||||||
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
Stream<List<EmailThread>> observeThreads(
|
Stream<List<EmailThread>> observeThreads(
|
||||||
@@ -178,8 +181,6 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? stackTrace,
|
|
||||||
bool isPermanent = false,
|
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -221,21 +222,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
|||||||
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
// Do not manually edit this file.
|
// Do not manually edit this file.
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'dart:async' as _i5;
|
import 'dart:async' as _i4;
|
||||||
|
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i7;
|
import 'package:mockito/src/dummies.dart' as _i6;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
import 'package:sharedinbox/core/models/account.dart' as _i5;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
|
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
|
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: avoid_redundant_argument_values
|
// ignore_for_file: avoid_redundant_argument_values
|
||||||
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
|
|||||||
// ignore_for_file: subtype_of_sealed_class
|
// ignore_for_file: subtype_of_sealed_class
|
||||||
// ignore_for_file: invalid_use_of_internal_member
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
|
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
|
||||||
_FakeMailbox_0(
|
_FakeEmailBody_0(
|
||||||
Object parent,
|
Object parent,
|
||||||
Invocation parentInvocation,
|
Invocation parentInvocation,
|
||||||
) : super(
|
) : super(
|
||||||
@@ -39,8 +39,9 @@ class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
|
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
|
||||||
_FakeEmailBody_1(
|
implements _i2.SyncEmailsResult {
|
||||||
|
_FakeSyncEmailsResult_1(
|
||||||
Object parent,
|
Object parent,
|
||||||
Invocation parentInvocation,
|
Invocation parentInvocation,
|
||||||
) : super(
|
) : super(
|
||||||
@@ -49,20 +50,9 @@ class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeSyncEmailsResult_2 extends _i1.SmartFake
|
class _FakeReliabilityResult_2 extends _i1.SmartFake
|
||||||
implements _i3.SyncEmailsResult {
|
implements _i2.ReliabilityResult {
|
||||||
_FakeSyncEmailsResult_2(
|
_FakeReliabilityResult_2(
|
||||||
Object parent,
|
|
||||||
Invocation parentInvocation,
|
|
||||||
) : super(
|
|
||||||
parent,
|
|
||||||
parentInvocation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FakeReliabilityResult_3 extends _i1.SmartFake
|
|
||||||
implements _i3.ReliabilityResult {
|
|
||||||
_FakeReliabilityResult_3(
|
|
||||||
Object parent,
|
Object parent,
|
||||||
Invocation parentInvocation,
|
Invocation parentInvocation,
|
||||||
) : super(
|
) : super(
|
||||||
@@ -74,32 +64,32 @@ class _FakeReliabilityResult_3 extends _i1.SmartFake
|
|||||||
/// A class which mocks [AccountRepository].
|
/// A class which mocks [AccountRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
|
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
|
||||||
MockAccountRepository() {
|
MockAccountRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
|
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#observeAccounts,
|
#observeAccounts,
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i6.Account>>.empty(),
|
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
|
||||||
) as _i5.Stream<List<_i6.Account>>);
|
) as _i4.Stream<List<_i5.Account>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
|
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getAccount,
|
#getAccount,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i6.Account?>.value(),
|
returnValue: _i4.Future<_i5.Account?>.value(),
|
||||||
) as _i5.Future<_i6.Account?>);
|
) as _i4.Future<_i5.Account?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> addAccount(
|
_i4.Future<void> addAccount(
|
||||||
_i6.Account? account,
|
_i5.Account? account,
|
||||||
String? password,
|
String? password,
|
||||||
) =>
|
) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
@@ -110,13 +100,13 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
|
|||||||
password,
|
password,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> updateAccount(
|
_i4.Future<void> updateAccount(
|
||||||
_i6.Account? account, {
|
_i5.Account? account, {
|
||||||
String? password,
|
String? password,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
@@ -125,65 +115,65 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
|
|||||||
[account],
|
[account],
|
||||||
{#password: password},
|
{#password: password},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#removeAccount,
|
#removeAccount,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getPassword,
|
#getPassword,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getPassword,
|
#getPassword,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<String>);
|
) as _i4.Future<String>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A class which mocks [MailboxRepository].
|
/// A class which mocks [MailboxRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
|
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
|
||||||
MockMailboxRepository() {
|
MockMailboxRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
|
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#observeMailboxes,
|
#observeMailboxes,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
|
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
|
||||||
) as _i5.Stream<List<_i2.Mailbox>>);
|
) as _i4.Stream<List<_i8.Mailbox>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#syncMailboxes,
|
#syncMailboxes,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i2.Mailbox?> findMailboxByRole(
|
_i4.Future<_i8.Mailbox?> findMailboxByRole(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? role,
|
String? role,
|
||||||
) =>
|
) =>
|
||||||
@@ -195,46 +185,18 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
|
|||||||
role,
|
role,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i2.Mailbox?>.value(),
|
returnValue: _i4.Future<_i8.Mailbox?>.value(),
|
||||||
) as _i5.Future<_i2.Mailbox?>);
|
) as _i4.Future<_i8.Mailbox?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#clearForResync,
|
#clearForResync,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
|
||||||
_i5.Future<_i2.Mailbox> createMailboxWithRole(
|
|
||||||
String? accountId,
|
|
||||||
String? name,
|
|
||||||
String? role,
|
|
||||||
) =>
|
|
||||||
(super.noSuchMethod(
|
|
||||||
Invocation.method(
|
|
||||||
#createMailboxWithRole,
|
|
||||||
[
|
|
||||||
accountId,
|
|
||||||
name,
|
|
||||||
role,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
|
|
||||||
this,
|
|
||||||
Invocation.method(
|
|
||||||
#createMailboxWithRole,
|
|
||||||
[
|
|
||||||
accountId,
|
|
||||||
name,
|
|
||||||
role,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
) as _i5.Future<_i2.Mailbox>);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A class which mocks [EmailRepository].
|
/// A class which mocks [EmailRepository].
|
||||||
@@ -246,13 +208,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
||||||
Invocation.getter(#onChangesQueued),
|
Invocation.getter(#onChangesQueued),
|
||||||
returnValue: _i5.Stream<String>.empty(),
|
returnValue: _i4.Stream<String>.empty(),
|
||||||
) as _i5.Stream<String>);
|
) as _i4.Stream<String>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.Email>> observeEmails(
|
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath, {
|
String? mailboxPath, {
|
||||||
int? limit = 50,
|
int? limit = 50,
|
||||||
@@ -266,11 +228,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
|
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.Email>>);
|
) as _i4.Stream<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.EmailThread>> observeThreads(
|
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath, {
|
String? mailboxPath, {
|
||||||
int? limit = 50,
|
int? limit = 50,
|
||||||
@@ -284,11 +246,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
String? threadId,
|
String? threadId,
|
||||||
@@ -302,36 +264,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
threadId,
|
threadId,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
|
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.Email>>);
|
) as _i4.Stream<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getEmail,
|
#getEmail,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i3.Email?>.value(),
|
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||||
) as _i5.Future<_i3.Email?>);
|
) as _i4.Future<_i2.Email?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
|
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getEmailBody,
|
#getEmailBody,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
|
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getEmailBody,
|
#getEmailBody,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i3.EmailBody>);
|
) as _i4.Future<_i2.EmailBody>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.SyncEmailsResult> syncEmails(
|
_i4.Future<_i2.SyncEmailsResult> syncEmails(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -344,7 +306,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue:
|
returnValue:
|
||||||
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
|
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#syncEmails,
|
#syncEmails,
|
||||||
@@ -354,10 +316,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i3.SyncEmailsResult>);
|
) as _i4.Future<_i2.SyncEmailsResult>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> setFlag(
|
_i4.Future<void> setFlag(
|
||||||
String? emailId, {
|
String? emailId, {
|
||||||
bool? seen,
|
bool? seen,
|
||||||
bool? flagged,
|
bool? flagged,
|
||||||
@@ -371,12 +333,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
#flagged: flagged,
|
#flagged: flagged,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> markAllAsRead(
|
_i4.Future<void> markAllAsRead(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -388,12 +350,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
mailboxPath,
|
mailboxPath,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> moveEmail(
|
_i4.Future<void> moveEmail(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
String? destMailboxPath,
|
String? destMailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -405,23 +367,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
destMailboxPath,
|
destMailboxPath,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#deleteEmail,
|
#deleteEmail,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String?>.value(),
|
returnValue: _i4.Future<String?>.value(),
|
||||||
) as _i5.Future<String?>);
|
) as _i4.Future<String?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> sendEmail(
|
_i4.Future<void> sendEmail(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
_i3.EmailDraft? draft,
|
_i2.EmailDraft? draft,
|
||||||
) =>
|
) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -431,14 +393,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
draft,
|
draft,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String> downloadAttachment(
|
_i4.Future<String> downloadAttachment(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
_i3.EmailAttachment? attachment,
|
_i2.EmailAttachment? attachment,
|
||||||
) =>
|
) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -448,7 +410,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
attachment,
|
attachment,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#downloadAttachment,
|
#downloadAttachment,
|
||||||
@@ -458,25 +420,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<String>);
|
) as _i4.Future<String>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
|
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#fetchRawRfc822,
|
#fetchRawRfc822,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#fetchRawRfc822,
|
#fetchRawRfc822,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<String>);
|
) as _i4.Future<String>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> searchEmails(
|
_i4.Future<List<_i2.Email>> searchEmails(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
String? query,
|
String? query,
|
||||||
@@ -490,11 +452,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
query,
|
query,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> searchEmailsGlobal(
|
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? query,
|
String? query,
|
||||||
) =>
|
) =>
|
||||||
@@ -506,11 +468,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
query,
|
query,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? address,
|
String? address,
|
||||||
) =>
|
) =>
|
||||||
@@ -522,11 +484,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
address,
|
address,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.EmailAddress>> searchAddresses(
|
_i4.Future<List<_i2.EmailAddress>> searchAddresses(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? query, {
|
String? query, {
|
||||||
int? limit = 10,
|
int? limit = 10,
|
||||||
@@ -541,11 +503,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue:
|
returnValue:
|
||||||
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
|
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
|
||||||
) as _i5.Future<List<_i3.EmailAddress>>);
|
) as _i4.Future<List<_i2.EmailAddress>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> flushPendingChanges(
|
_i4.Future<int> flushPendingChanges(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? password,
|
String? password,
|
||||||
) =>
|
) =>
|
||||||
@@ -557,42 +519,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
password,
|
password,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
|
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
|
||||||
String? accountId) =>
|
String? accountId) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#observeFailedMutations,
|
#observeFailedMutations,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
|
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.FailedMutation>>);
|
) as _i4.Stream<List<_i2.FailedMutation>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#discardMutation,
|
#discardMutation,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#retryMutation,
|
#retryMutation,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<bool> cancelPendingChange(
|
_i4.Future<bool> cancelPendingChange(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
String? changeType,
|
String? changeType,
|
||||||
) =>
|
) =>
|
||||||
@@ -604,11 +566,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
changeType,
|
changeType,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<bool>.value(false),
|
returnValue: _i4.Future<bool>.value(false),
|
||||||
) as _i5.Future<bool>);
|
) as _i4.Future<bool>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> snoozeEmail(
|
_i4.Future<void> snoozeEmail(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
DateTime? until,
|
DateTime? until,
|
||||||
) =>
|
) =>
|
||||||
@@ -620,32 +582,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
until,
|
until,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#wakeUpEmails,
|
#wakeUpEmails,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
|
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#restoreEmails,
|
#restoreEmails,
|
||||||
[emails],
|
[emails],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.Email?> findEmailByMessageId(
|
_i4.Future<_i2.Email?> findEmailByMessageId(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
) =>
|
) =>
|
||||||
@@ -657,20 +619,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
messageId,
|
messageId,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i3.Email?>.value(),
|
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||||
) as _i5.Future<_i3.Email?>);
|
) as _i4.Future<_i2.Email?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
|
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#applySieveRules,
|
#applySieveRules,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<void> watchJmapPush(
|
_i4.Stream<void> watchJmapPush(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? password,
|
String? password,
|
||||||
) =>
|
) =>
|
||||||
@@ -682,11 +644,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
password,
|
password,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<void>.empty(),
|
returnValue: _i4.Stream<void>.empty(),
|
||||||
) as _i5.Stream<void>);
|
) as _i4.Stream<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.ReliabilityResult> verifySyncReliability(
|
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -699,7 +661,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue:
|
returnValue:
|
||||||
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
|
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#verifySyncReliability,
|
#verifySyncReliability,
|
||||||
@@ -709,15 +671,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i3.ReliabilityResult>);
|
) as _i4.Future<_i2.ReliabilityResult>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#clearForResync,
|
#clearForResync,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ void main() {
|
|||||||
// startup, throwing PlatformException(channel-error, ...).
|
// startup, throwing PlatformException(channel-error, ...).
|
||||||
// registerBackgroundSync() must absorb the failure and let the app continue.
|
// registerBackgroundSync() must absorb the failure and let the app continue.
|
||||||
test(
|
test(
|
||||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||||
() async {
|
() async {
|
||||||
// In the unit-test environment the native WorkManager plugin is not
|
// In the unit-test environment the native WorkManager plugin is not
|
||||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||||
// MissingPluginException. The fix catches it. This test fails before the
|
// MissingPluginException. The fix catches it. This test fails before the
|
||||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||||
await expectLater(registerBackgroundSync(), completes);
|
await expectLater(registerBackgroundSync(), completes);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,8 @@ void main() {
|
|||||||
final result = injectInlineImages(html, msg);
|
final result = injectInlineImages(html, msg);
|
||||||
|
|
||||||
// Extract base64 payload from the data URI.
|
// Extract base64 payload from the data URI.
|
||||||
final match = RegExp(
|
final match =
|
||||||
r'data:image/png;base64,([A-Za-z0-9+/=]+)',
|
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
|
||||||
).firstMatch(result);
|
|
||||||
expect(match, isNotNull);
|
expect(match, isNotNull);
|
||||||
final decoded = base64.decode(match!.group(1)!);
|
final decoded = base64.decode(match!.group(1)!);
|
||||||
expect(decoded.length, greaterThan(0));
|
expect(decoded.length, greaterThan(0));
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ abstract class EmailRepositoryContract {
|
|||||||
void run() {
|
void run() {
|
||||||
test('observeEmails starts empty', () async {
|
test('observeEmails starts empty', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty);
|
expect(
|
||||||
|
await repo.observeEmails(_account.id, 'INBOX').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observeEmails emits inserted email', () async {
|
test('observeEmails emits inserted email', () async {
|
||||||
@@ -58,7 +61,10 @@ abstract class EmailRepositoryContract {
|
|||||||
test('observeEmails only returns emails for the given mailbox', () async {
|
test('observeEmails only returns emails for the given mailbox', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||||
expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty);
|
expect(
|
||||||
|
await repo.observeEmails(_account.id, 'Sent').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observeEmails orders by receivedAt descending', () async {
|
test('observeEmails orders by receivedAt descending', () async {
|
||||||
@@ -110,7 +116,11 @@ abstract class EmailRepositoryContract {
|
|||||||
|
|
||||||
test('setFlag flagged updates isFlagged', () async {
|
test('setFlag flagged updates isFlagged', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX');
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:11',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
await repo.setFlag('er-acc:11', flagged: true);
|
await repo.setFlag('er-acc:11', flagged: true);
|
||||||
final email = await repo.getEmail('er-acc:11');
|
final email = await repo.getEmail('er-acc:11');
|
||||||
expect(email!.isFlagged, isTrue);
|
expect(email!.isFlagged, isTrue);
|
||||||
@@ -147,7 +157,10 @@ abstract class EmailRepositoryContract {
|
|||||||
|
|
||||||
test('observeThreads starts empty', () async {
|
test('observeThreads starts empty', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
|
expect(
|
||||||
|
await repo.observeThreads(_account.id, 'INBOX').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,49 +453,47 @@ void main() {
|
|||||||
expect(results.first.subject, 'foobar baz');
|
expect(results.first.subject, 'foobar baz');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('searchAddresses returns results sorted by most recently used',
|
||||||
'searchAddresses returns results sorted by most recently used',
|
() async {
|
||||||
() async {
|
final r = _makeRepos();
|
||||||
final r = _makeRepos();
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
|
||||||
|
|
||||||
final older = DateTime(2024);
|
final older = DateTime(2024);
|
||||||
final newer = DateTime(2024, 6);
|
final newer = DateTime(2024, 6);
|
||||||
|
|
||||||
// Two emails — older one has alice@, newer one has bob@.
|
// Two emails — older one has alice@, newer one has bob@.
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'acc-1:old',
|
id: 'acc-1:old',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 1,
|
uid: 1,
|
||||||
receivedAt: older,
|
receivedAt: older,
|
||||||
toAddresses: const Value(
|
toAddresses: const Value(
|
||||||
'[{"name":"Alice","email":"alice@example.com"}]',
|
'[{"name":"Alice","email":"alice@example.com"}]',
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
await r.db.into(r.db.emails).insert(
|
);
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:new',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:new',
|
||||||
mailboxPath: 'Sent',
|
accountId: 'acc-1',
|
||||||
uid: 2,
|
mailboxPath: 'Sent',
|
||||||
receivedAt: newer,
|
uid: 2,
|
||||||
toAddresses: const Value(
|
receivedAt: newer,
|
||||||
'[{"name":"Bob","email":"bob@example.com"}]',
|
toAddresses: const Value(
|
||||||
),
|
'[{"name":"Bob","email":"bob@example.com"}]',
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Query matching both; newer (bob) should come first.
|
// Query matching both; newer (bob) should come first.
|
||||||
final results = await r.emails.searchAddresses(null, 'example');
|
final results = await r.emails.searchAddresses(null, 'example');
|
||||||
expect(results.map((a) => a.email).toList(), [
|
expect(
|
||||||
'bob@example.com',
|
results.map((a) => a.email).toList(),
|
||||||
'alice@example.com',
|
['bob@example.com', 'alice@example.com'],
|
||||||
]);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// ── IMAP method tests ────────────────────────────────────────────────────
|
// ── IMAP method tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -699,47 +697,47 @@ void main() {
|
|||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('snooze flush selects src mailbox and moves email to Snoozed',
|
||||||
'snooze flush selects src mailbox and moves email to Snoozed',
|
() async {
|
||||||
() async {
|
final spy = SnoozeSpyImapClient();
|
||||||
final spy = SnoozeSpyImapClient();
|
final r = _makeRepos(
|
||||||
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
|
imapConnect: (_, __, ___) async => spy,
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
);
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:5',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:5',
|
||||||
mailboxPath: 'Snoozed',
|
accountId: 'acc-1',
|
||||||
uid: 5,
|
mailboxPath: 'Snoozed',
|
||||||
receivedAt: DateTime(2024),
|
uid: 5,
|
||||||
),
|
receivedAt: DateTime(2024),
|
||||||
);
|
),
|
||||||
await r.db.into(r.db.pendingChanges).insert(
|
);
|
||||||
PendingChangesCompanion.insert(
|
await r.db.into(r.db.pendingChanges).insert(
|
||||||
accountId: 'acc-1',
|
PendingChangesCompanion.insert(
|
||||||
resourceType: 'Email',
|
accountId: 'acc-1',
|
||||||
resourceId: 'acc-1:5',
|
resourceType: 'Email',
|
||||||
changeType: 'snooze',
|
resourceId: 'acc-1:5',
|
||||||
payload: jsonEncode({
|
changeType: 'snooze',
|
||||||
'uid': 5,
|
payload: jsonEncode({
|
||||||
'src': 'INBOX',
|
'uid': 5,
|
||||||
'dest': 'Snoozed',
|
'src': 'INBOX',
|
||||||
'until': '2026-05-10T15:00:00.000',
|
'dest': 'Snoozed',
|
||||||
}),
|
'until': '2026-05-10T15:00:00.000',
|
||||||
createdAt: DateTime.now(),
|
}),
|
||||||
),
|
createdAt: DateTime.now(),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('acc-1', 'pw');
|
await r.emails.flushPendingChanges('acc-1', 'pw');
|
||||||
|
|
||||||
// Change successfully applied — removed from queue.
|
// Change successfully applied — removed from queue.
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
// Source mailbox extracted from 'src', not 'mailboxPath'.
|
// Source mailbox extracted from 'src', not 'mailboxPath'.
|
||||||
expect(spy.selectedMailbox, 'INBOX');
|
expect(spy.selectedMailbox, 'INBOX');
|
||||||
expect(spy.createdMailbox, 'Snoozed');
|
expect(spy.createdMailbox, 'Snoozed');
|
||||||
expect(spy.movedToMailbox, 'Snoozed');
|
expect(spy.movedToMailbox, 'Snoozed');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Snooze', () {
|
group('Snooze', () {
|
||||||
@@ -1642,123 +1640,119 @@ void main() {
|
|||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
|
||||||
'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
|
() async {
|
||||||
() async {
|
final List<Map<String, dynamic>> capturedBodies = [];
|
||||||
final List<Map<String, dynamic>> capturedBodies = [];
|
final client = MockClient((req) async {
|
||||||
final client = MockClient((req) async {
|
if (req.url.path.contains('well-known')) {
|
||||||
if (req.url.path.contains('well-known')) {
|
return http.Response(
|
||||||
return http.Response(
|
jsonEncode({
|
||||||
jsonEncode({
|
'apiUrl': 'https://jmap.example.com/api/',
|
||||||
'apiUrl': 'https://jmap.example.com/api/',
|
'accounts': {
|
||||||
'accounts': {
|
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
||||||
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
},
|
||||||
},
|
'primaryAccounts': {
|
||||||
'primaryAccounts': {
|
'urn:ietf:params:jmap:core': 'acct1',
|
||||||
'urn:ietf:params:jmap:core': 'acct1',
|
'urn:ietf:params:jmap:mail': 'acct1',
|
||||||
'urn:ietf:params:jmap:mail': 'acct1',
|
},
|
||||||
},
|
'capabilities': {},
|
||||||
'capabilities': {},
|
'username': 'alice@example.com',
|
||||||
'username': 'alice@example.com',
|
'state': 'sess1',
|
||||||
'state': 'sess1',
|
}),
|
||||||
}),
|
200,
|
||||||
200,
|
);
|
||||||
);
|
}
|
||||||
}
|
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
capturedBodies.add(body);
|
||||||
capturedBodies.add(body);
|
final calls = body['methodCalls'] as List;
|
||||||
final calls = body['methodCalls'] as List;
|
final methodName = (calls.first as List)[0] as String;
|
||||||
final methodName = (calls.first as List)[0] as String;
|
if (methodName == 'Mailbox/set') {
|
||||||
if (methodName == 'Mailbox/set') {
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'sessionState': 's1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'created': {
|
|
||||||
'new-snoozed': {'id': 'mbx-snoozed'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'sessionState': 's1',
|
'sessionState': 's1',
|
||||||
'methodResponses': [
|
'methodResponses': [
|
||||||
[
|
[
|
||||||
'Email/set',
|
'Mailbox/set',
|
||||||
{'accountId': 'acct1', 'updated': {}},
|
{
|
||||||
|
'accountId': 'acct1',
|
||||||
|
'created': {
|
||||||
|
'new-snoozed': {'id': 'mbx-snoozed'},
|
||||||
|
},
|
||||||
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
return http.Response(
|
||||||
final r = _makeRepos(httpClient: client);
|
jsonEncode({
|
||||||
await seedChange(
|
'sessionState': 's1',
|
||||||
r.db,
|
'methodResponses': [
|
||||||
r.accounts,
|
[
|
||||||
changeType: 'snooze',
|
'Email/set',
|
||||||
payload: jsonEncode({
|
{'accountId': 'acct1', 'updated': {}},
|
||||||
'uid': 0,
|
'0',
|
||||||
'src': 'mbx-inbox',
|
],
|
||||||
'dest': 'Snoozed',
|
],
|
||||||
'until': '2026-05-10T15:00:00.000',
|
|
||||||
}),
|
}),
|
||||||
|
200,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
final r = _makeRepos(httpClient: client);
|
||||||
|
await seedChange(
|
||||||
|
r.db,
|
||||||
|
r.accounts,
|
||||||
|
changeType: 'snooze',
|
||||||
|
payload: jsonEncode({
|
||||||
|
'uid': 0,
|
||||||
|
'src': 'mbx-inbox',
|
||||||
|
'dest': 'Snoozed',
|
||||||
|
'until': '2026-05-10T15:00:00.000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Change successfully applied — removed from queue.
|
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
|
||||||
|
|
||||||
// First API call should be Mailbox/set to create the Snoozed folder.
|
// Change successfully applied — removed from queue.
|
||||||
expect(capturedBodies, hasLength(2));
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
final firstCall =
|
|
||||||
((capturedBodies.first['methodCalls'] as List).first as List)[0];
|
|
||||||
expect(firstCall, 'Mailbox/set');
|
|
||||||
|
|
||||||
// Second call should be Email/set using the newly created mailbox ID.
|
// First API call should be Mailbox/set to create the Snoozed folder.
|
||||||
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
expect(capturedBodies, hasLength(2));
|
||||||
as List)[1] as Map<String, dynamic>;
|
final firstCall =
|
||||||
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
((capturedBodies.first['methodCalls'] as List).first as List)[0];
|
||||||
as Map<String, dynamic>;
|
expect(firstCall, 'Mailbox/set');
|
||||||
expect(update['mailboxIds/mbx-snoozed'], true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
// Second call should be Email/set using the newly created mailbox ID.
|
||||||
'snooze uses existing mailbox ID when dest is already a JMAP ID',
|
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
||||||
() async {
|
as List)[1] as Map<String, dynamic>;
|
||||||
final r = _makeRepos(httpClient: mockFlush(200));
|
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||||
await seedChange(
|
as Map<String, dynamic>;
|
||||||
r.db,
|
expect(update['mailboxIds/mbx-snoozed'], true);
|
||||||
r.accounts,
|
});
|
||||||
changeType: 'snooze',
|
|
||||||
payload: jsonEncode({
|
|
||||||
'uid': 0,
|
|
||||||
'src': 'mbx-inbox',
|
|
||||||
'dest': 'mbx-snoozed',
|
|
||||||
'until': '2026-05-10T15:00:00.000',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
test('snooze uses existing mailbox ID when dest is already a JMAP ID',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos(httpClient: mockFlush(200));
|
||||||
|
await seedChange(
|
||||||
|
r.db,
|
||||||
|
r.accounts,
|
||||||
|
changeType: 'snooze',
|
||||||
|
payload: jsonEncode({
|
||||||
|
'uid': 0,
|
||||||
|
'src': 'mbx-inbox',
|
||||||
|
'dest': 'mbx-snoozed',
|
||||||
|
'until': '2026-05-10T15:00:00.000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Change applied without needing Mailbox/set (dest was already a valid ID).
|
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
|
||||||
},
|
// Change applied without needing Mailbox/set (dest was already a valid ID).
|
||||||
);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('JMAP syncEmails body caching', () {
|
group('JMAP syncEmails body caching', () {
|
||||||
@@ -2288,42 +2282,41 @@ void main() {
|
|||||||
|
|
||||||
group('concurrent moves', () {
|
group('concurrent moves', () {
|
||||||
test(
|
test(
|
||||||
'two simultaneous moves enqueue two changes and leave email in last destination',
|
'two simultaneous moves enqueue two changes and leave email in last destination',
|
||||||
() async {
|
() async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'acc-1:5',
|
id: 'acc-1:5',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 5,
|
uid: 5,
|
||||||
receivedAt: DateTime(2024),
|
receivedAt: DateTime(2024),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fire both moves without awaiting to exercise concurrent enqueue logic.
|
// Fire both moves without awaiting to exercise concurrent enqueue logic.
|
||||||
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
|
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
|
||||||
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
|
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
|
||||||
await Future.wait([f1, f2]);
|
await Future.wait([f1, f2]);
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes, hasLength(2));
|
expect(changes, hasLength(2));
|
||||||
expect(changes.map((c) => c.changeType), everyElement('move'));
|
expect(changes.map((c) => c.changeType), everyElement('move'));
|
||||||
|
|
||||||
final destinations =
|
final destinations =
|
||||||
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
||||||
expect(destinations, containsAll(['Archive', 'Trash']));
|
expect(destinations, containsAll(['Archive', 'Trash']));
|
||||||
|
|
||||||
final email = await r.emails.getEmail('acc-1:5');
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
expect(
|
expect(
|
||||||
email!.mailboxPath,
|
email!.mailboxPath,
|
||||||
anyOf('Archive', 'Trash'),
|
anyOf('Archive', 'Trash'),
|
||||||
reason:
|
reason:
|
||||||
'email must be optimistically moved to one of the two destinations',
|
'email must be optimistically moved to one of the two destinations',
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('IMAP SMTP auth failure', () {
|
group('IMAP SMTP auth failure', () {
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ abstract class MailboxRepositoryContract {
|
|||||||
|
|
||||||
test('findMailboxByRole returns null when no match', () async {
|
test('findMailboxByRole returns null when no match', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull);
|
expect(
|
||||||
|
await repo.findMailboxByRole(_account.id, 'archive'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findMailboxByRole returns the matching mailbox', () async {
|
test('findMailboxByRole returns the matching mailbox', () async {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
|||||||
|
|
||||||
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
import 'db_test_helper.dart';
|
import 'db_test_helper.dart';
|
||||||
import 'fake_imap.dart' show SnoozeSpyImapClient;
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _account = Account(
|
const _account = Account(
|
||||||
@@ -433,179 +432,5 @@ void main() {
|
|||||||
expect(result, isNotNull);
|
expect(result, isNotNull);
|
||||||
expect(result!.role, 'inbox');
|
expect(result!.role, 'inbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
group('createMailboxWithRole', () {
|
|
||||||
test('IMAP: creates mailbox on server and persists with role', () async {
|
|
||||||
final spy = SnoozeSpyImapClient();
|
|
||||||
final db = openTestDatabase();
|
|
||||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
|
||||||
final mailboxes = MailboxRepositoryImpl(
|
|
||||||
db,
|
|
||||||
accounts,
|
|
||||||
imapConnect: (_, __, ___) async => spy,
|
|
||||||
);
|
|
||||||
await accounts.addAccount(_account, 'pw');
|
|
||||||
|
|
||||||
final result = await mailboxes.createMailboxWithRole(
|
|
||||||
'acc-1',
|
|
||||||
'Archive',
|
|
||||||
'archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy.createdMailbox, 'Archive');
|
|
||||||
expect(result.name, 'Archive');
|
|
||||||
expect(result.role, 'archive');
|
|
||||||
expect(result.path, 'Archive');
|
|
||||||
|
|
||||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
|
||||||
expect(found, isNotNull);
|
|
||||||
expect(found!.name, 'Archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('JMAP: creates mailbox on server and persists with role', () async {
|
|
||||||
final r = _makeRepos(
|
|
||||||
httpClient: _mockJmap(
|
|
||||||
apiResponses: [
|
|
||||||
{
|
|
||||||
'sessionState': 'sess1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'created': {
|
|
||||||
'new-mailbox': {'id': 'mbx-archive'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
|
|
||||||
final result = await r.mailboxes.createMailboxWithRole(
|
|
||||||
'jmap-1',
|
|
||||||
'Archive',
|
|
||||||
'archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.name, 'Archive');
|
|
||||||
expect(result.role, 'archive');
|
|
||||||
expect(result.path, 'mbx-archive');
|
|
||||||
|
|
||||||
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
|
|
||||||
expect(found, isNotNull);
|
|
||||||
expect(found!.name, 'Archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('JMAP: throws when server returns no created ID', () async {
|
|
||||||
final r = _makeRepos(
|
|
||||||
httpClient: _mockJmap(
|
|
||||||
apiResponses: [
|
|
||||||
{
|
|
||||||
'sessionState': 'sess1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'created': null,
|
|
||||||
'notCreated': {
|
|
||||||
'new-mailbox': {'type': 'serverFail'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
|
||||||
throwsA(isA<Exception>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('syncMailboxes IMAP preserves manually-set role', () {
|
|
||||||
test(
|
|
||||||
'existing role is kept when server returns no special-use flag',
|
|
||||||
() async {
|
|
||||||
final spy = SnoozeSpyImapClient();
|
|
||||||
// Make listMailboxes return a plain folder without \Archive.
|
|
||||||
final db = openTestDatabase();
|
|
||||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
|
||||||
|
|
||||||
// Override listMailboxes to return one plain folder.
|
|
||||||
final fakeClient = _PlainArchiveImapClient();
|
|
||||||
final mailboxes = MailboxRepositoryImpl(
|
|
||||||
db,
|
|
||||||
accounts,
|
|
||||||
imapConnect: (_, __, ___) async => fakeClient,
|
|
||||||
);
|
|
||||||
await accounts.addAccount(_account, 'pw');
|
|
||||||
|
|
||||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
|
||||||
await db.into(db.mailboxes).insert(
|
|
||||||
MailboxesCompanion.insert(
|
|
||||||
id: 'acc-1:Archive',
|
|
||||||
accountId: 'acc-1',
|
|
||||||
path: 'Archive',
|
|
||||||
name: 'Archive',
|
|
||||||
role: const Value('archive'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await mailboxes.syncMailboxes('acc-1');
|
|
||||||
|
|
||||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
|
||||||
expect(
|
|
||||||
found,
|
|
||||||
isNotNull,
|
|
||||||
reason: 'Manually-set role should be preserved after sync',
|
|
||||||
);
|
|
||||||
expect(found!.path, 'Archive');
|
|
||||||
// Suppress unused warning on spy.
|
|
||||||
expect(spy, isNotNull);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fake IMAP client that lists one mailbox named 'Archive' without any
|
|
||||||
/// special-use flags, and logs out cleanly.
|
|
||||||
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
|
|
||||||
@override
|
|
||||||
Future<List<imap.Mailbox>> listMailboxes({
|
|
||||||
String path = '""',
|
|
||||||
bool recursive = false,
|
|
||||||
List<String>? mailboxPatterns,
|
|
||||||
List<String>? selectionOptions,
|
|
||||||
List<imap.ReturnOption>? returnOptions,
|
|
||||||
}) async =>
|
|
||||||
[
|
|
||||||
imap.Mailbox(
|
|
||||||
encodedName: 'Archive',
|
|
||||||
encodedPath: 'Archive',
|
|
||||||
pathSeparator: '/',
|
|
||||||
flags: [], // No \Archive special-use flag
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<imap.Mailbox> statusMailbox(
|
|
||||||
imap.Mailbox mailbox,
|
|
||||||
List<imap.StatusFlags> flags,
|
|
||||||
) async =>
|
|
||||||
mailbox;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<dynamic> logout() async {}
|
|
||||||
}
|
|
||||||
|
|||||||
+69
-116
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 36);
|
expect(db.schemaVersion, 32);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,50 +178,35 @@ void main() {
|
|||||||
|
|
||||||
// v28: mime_tree_json column on email_bodies.
|
// v28: mime_tree_json column on email_bodies.
|
||||||
await db
|
await db
|
||||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
.customSelect(
|
||||||
|
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||||
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
// v29: local_sieve_scripts table.
|
// v29: local_sieve_scripts table.
|
||||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||||
|
|
||||||
// v30: duration_ms column on sync_log_mailboxes.
|
// v30: duration_ms column on sync_log_mailboxes.
|
||||||
final syncLogMailboxColumns = await _tableColumns(
|
final syncLogMailboxColumns =
|
||||||
db,
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
'sync_log_mailboxes',
|
|
||||||
);
|
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
|
|
||||||
// v32: local_sieve_applied table.
|
// v32: local_sieve_applied table.
|
||||||
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
||||||
|
|
||||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
|
||||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
|
||||||
expect(syncLogColumns, contains('error_stack_trace'));
|
|
||||||
expect(syncLogColumns, contains('is_permanent'));
|
|
||||||
|
|
||||||
// v34: user_preferences table.
|
|
||||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
|
||||||
|
|
||||||
// v35: mail_view_button_position column on user_preferences.
|
|
||||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
|
||||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
|
||||||
|
|
||||||
// v36: after_mail_view_action column on user_preferences.
|
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
||||||
() async {
|
() async {
|
||||||
final dbFile = File('test_migration_v22.db');
|
final dbFile = File('test_migration_v22.db');
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
|
||||||
// Build a v22 database schema directly with raw SQL.
|
// Build a v22 database schema directly with raw SQL.
|
||||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE accounts (
|
CREATE TABLE accounts (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
@@ -242,7 +227,7 @@ void main() {
|
|||||||
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
|
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE drafts (
|
CREATE TABLE drafts (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id TEXT NULL,
|
account_id TEXT NULL,
|
||||||
@@ -254,7 +239,7 @@ void main() {
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE mailboxes (
|
CREATE TABLE mailboxes (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
@@ -265,7 +250,7 @@ void main() {
|
|||||||
role TEXT NULL
|
role TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE emails (
|
CREATE TABLE emails (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
@@ -289,7 +274,7 @@ void main() {
|
|||||||
snoozed_from_mailbox_path TEXT NULL
|
snoozed_from_mailbox_path TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE threads (
|
CREATE TABLE threads (
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
mailbox_path TEXT NOT NULL,
|
mailbox_path TEXT NOT NULL,
|
||||||
@@ -306,7 +291,7 @@ void main() {
|
|||||||
PRIMARY KEY (account_id, mailbox_path, id)
|
PRIMARY KEY (account_id, mailbox_path, id)
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE email_bodies (
|
CREATE TABLE email_bodies (
|
||||||
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
|
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
|
||||||
text_body TEXT NULL,
|
text_body TEXT NULL,
|
||||||
@@ -316,7 +301,7 @@ void main() {
|
|||||||
headers_json TEXT NULL
|
headers_json TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE sync_logs (
|
CREATE TABLE sync_logs (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
@@ -333,7 +318,7 @@ void main() {
|
|||||||
protocol_log TEXT NULL
|
protocol_log TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE sync_log_mailboxes (
|
CREATE TABLE sync_log_mailboxes (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
|
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
|
||||||
@@ -343,81 +328,64 @@ void main() {
|
|||||||
bytes_transferred INTEGER NOT NULL DEFAULT 0
|
bytes_transferred INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('PRAGMA user_version = 22;');
|
rawDb.execute('PRAGMA user_version = 22;');
|
||||||
rawDb.close();
|
rawDb.close();
|
||||||
|
|
||||||
final db = AppDatabase(NativeDatabase(dbFile));
|
final db = AppDatabase(NativeDatabase(dbFile));
|
||||||
// Trigger migration.
|
// Trigger migration.
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
final emailColumns = await _tableColumns(db, 'emails');
|
final emailColumns = await _tableColumns(db, 'emails');
|
||||||
expect(emailColumns, contains('list_unsubscribe_header'));
|
expect(emailColumns, contains('list_unsubscribe_header'));
|
||||||
|
|
||||||
final draftColumns = await _tableColumns(db, 'drafts');
|
final draftColumns = await _tableColumns(db, 'drafts');
|
||||||
expect(draftColumns, contains('imap_server_id'));
|
expect(draftColumns, contains('imap_server_id'));
|
||||||
|
|
||||||
// v25: new indexes on mailboxes and threads.
|
// v25: new indexes on mailboxes and threads.
|
||||||
final allIndexes = await db
|
final allIndexes = await db
|
||||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
.get();
|
.get();
|
||||||
final indexNames =
|
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||||
allIndexes.map((r) => r.read<String>('name')).toSet();
|
expect(indexNames, contains('mailboxes_account_id'));
|
||||||
expect(indexNames, contains('mailboxes_account_id'));
|
expect(indexNames, contains('threads_latest_date'));
|
||||||
expect(indexNames, contains('threads_latest_date'));
|
|
||||||
|
|
||||||
// v26: FTS5 virtual table and triggers.
|
// v26: FTS5 virtual table and triggers.
|
||||||
final allTriggers = await db
|
final allTriggers = await db
|
||||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||||
.get();
|
.get();
|
||||||
final triggerNames =
|
final triggerNames =
|
||||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||||
expect(
|
expect(
|
||||||
triggerNames,
|
triggerNames,
|
||||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||||
);
|
);
|
||||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||||
|
|
||||||
// v27: search_history_entries table.
|
// v27: search_history_entries table.
|
||||||
await db
|
await db
|
||||||
.customSelect('SELECT count(*) FROM search_history_entries')
|
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
// v28: mime_tree_json column on email_bodies.
|
// v28: mime_tree_json column on email_bodies.
|
||||||
await db
|
await db
|
||||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
.customSelect(
|
||||||
.get();
|
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
// v29: local_sieve_scripts table.
|
// v29: local_sieve_scripts table.
|
||||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||||
|
|
||||||
// v30: duration_ms column on sync_log_mailboxes.
|
// v30: duration_ms column on sync_log_mailboxes.
|
||||||
final syncLogMailboxColumns = await _tableColumns(
|
final syncLogMailboxColumns =
|
||||||
db,
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
'sync_log_mailboxes',
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
);
|
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
|
||||||
|
|
||||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
await db.close();
|
||||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
expect(syncLogColumns, contains('error_stack_trace'));
|
});
|
||||||
expect(syncLogColumns, contains('is_permanent'));
|
|
||||||
|
|
||||||
// v34: user_preferences table.
|
test('fresh install creates all tables at schemaVersion 32', () async {
|
||||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
|
||||||
|
|
||||||
// v35: mail_view_button_position column on user_preferences.
|
|
||||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
|
||||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
|
||||||
|
|
||||||
// v36: after_mail_view_action column on user_preferences.
|
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
|
||||||
|
|
||||||
await db.close();
|
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -444,7 +412,6 @@ void main() {
|
|||||||
'local_sieve_scripts', // v29
|
'local_sieve_scripts', // v29
|
||||||
'share_keys', // v31
|
'share_keys', // v31
|
||||||
'local_sieve_applied', // v32
|
'local_sieve_applied', // v32
|
||||||
'user_preferences', // v34
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -455,24 +422,10 @@ void main() {
|
|||||||
expect(draftColumns, contains('imap_server_id'));
|
expect(draftColumns, contains('imap_server_id'));
|
||||||
|
|
||||||
// v30: duration_ms column on sync_log_mailboxes.
|
// v30: duration_ms column on sync_log_mailboxes.
|
||||||
final syncLogMailboxColumns = await _tableColumns(
|
final syncLogMailboxColumns =
|
||||||
db,
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
'sync_log_mailboxes',
|
|
||||||
);
|
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
|
|
||||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
|
||||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
|
||||||
expect(syncLogColumns, contains('error_stack_trace'));
|
|
||||||
expect(syncLogColumns, contains('is_permanent'));
|
|
||||||
|
|
||||||
// v35: mail_view_button_position column on user_preferences.
|
|
||||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
|
||||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
|
||||||
|
|
||||||
// v36: after_mail_view_action column on user_preferences.
|
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,15 +9,14 @@ void main() {
|
|||||||
// absent at startup, throwing MissingPluginException (or a similar error).
|
// absent at startup, throwing MissingPluginException (or a similar error).
|
||||||
// initNotifications() must absorb the failure and let the app continue.
|
// initNotifications() must absorb the failure and let the app continue.
|
||||||
test(
|
test(
|
||||||
'initNotifications completes without throwing when plugin is unavailable',
|
'initNotifications completes without throwing when plugin is unavailable',
|
||||||
() async {
|
() async {
|
||||||
// In the unit-test environment the native plugin is not registered, so
|
// In the unit-test environment the native plugin is not registered, so
|
||||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||||
// false. This test fails before the fix (exception propagates) and passes
|
// false. This test fails before the fix (exception propagates) and passes
|
||||||
// after it (exception is swallowed).
|
// after it (exception is swallowed).
|
||||||
await expectLater(initNotifications(), completes);
|
await expectLater(initNotifications(), completes);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('showNewMailNotification completes without throwing', () async {
|
test('showNewMailNotification completes without throwing', () async {
|
||||||
// Platform.isAndroid is false in tests, so this returns early without
|
// Platform.isAndroid is false in tests, so this returns early without
|
||||||
|
|||||||
@@ -62,21 +62,6 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
null;
|
null;
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmails implements EmailRepository {
|
class _FakeEmails implements EmailRepository {
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ class _FakeAccounts implements AccountRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
||||||
@override
|
@override
|
||||||
Future<Account?> getAccount(String id) async => accounts
|
Future<Account?> getAccount(String id) async =>
|
||||||
.cast<Account?>()
|
accounts.cast<Account?>().firstWhere(
|
||||||
.firstWhere((a) => a?.id == id, orElse: () => null);
|
(a) => a?.id == id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
Future<void> addAccount(Account account, String password) async {}
|
Future<void> addAccount(Account account, String password) async {}
|
||||||
@override
|
@override
|
||||||
@@ -52,21 +54,6 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
null;
|
null;
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CountingEmails implements EmailRepository {
|
class _CountingEmails implements EmailRepository {
|
||||||
@@ -92,7 +79,11 @@ class _CountingEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
Stream<List<Email>> observeEmails(
|
||||||
|
String a,
|
||||||
|
String m, {
|
||||||
|
int limit = 50,
|
||||||
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
Stream<List<EmailThread>> observeThreads(
|
Stream<List<EmailThread>> observeThreads(
|
||||||
@@ -179,8 +170,6 @@ class _FakeSyncLog implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? stackTrace,
|
|
||||||
bool isPermanent = false,
|
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ void main() {
|
|||||||
test('parsePublicKeyQr returns null for invalid input', () {
|
test('parsePublicKeyQr returns null for invalid input', () {
|
||||||
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
|
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
|
||||||
expect(
|
expect(
|
||||||
ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
|
ShareEncryptionService.parsePublicKeyQr(
|
||||||
|
'sharedinbox.de:pubkey:v1:!!!',
|
||||||
|
),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ void main() {
|
|||||||
SieveRule(
|
SieveRule(
|
||||||
joinType: 'single',
|
joinType: 'single',
|
||||||
conditions: [
|
conditions: [
|
||||||
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
|
HeaderCondition(
|
||||||
|
['from', 'reply-to'],
|
||||||
|
':is',
|
||||||
|
['boss@work.com'],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
FlagAction([r'\Important']),
|
FlagAction([r'\Important']),
|
||||||
@@ -117,10 +121,8 @@ void main() {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final ctx = interp.execute(
|
final ctx =
|
||||||
rules,
|
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
|
||||||
_email(subject: 'Weekly Newsletter Issue'),
|
|
||||||
);
|
|
||||||
expect(ctx.targetFolders, contains('Bulk'));
|
expect(ctx.targetFolders, contains('Bulk'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -261,9 +261,8 @@ if exists "X-Spam-Flag" {
|
|||||||
|
|
||||||
group('SieveParser — rule model', () {
|
group('SieveParser — rule model', () {
|
||||||
test('simple if produces one rule with branchGroupId', () {
|
test('simple if produces one rule with branchGroupId', () {
|
||||||
final rules = parser.parse(
|
final rules =
|
||||||
'if header :contains "Subject" "x" { discard; }',
|
parser.parse('if header :contains "Subject" "x" { discard; }');
|
||||||
);
|
|
||||||
expect(rules, hasLength(1));
|
expect(rules, hasLength(1));
|
||||||
expect(rules.first.branchGroupId, isNotNull);
|
expect(rules.first.branchGroupId, isNotNull);
|
||||||
expect(rules.first.conditions, hasLength(1));
|
expect(rules.first.conditions, hasLength(1));
|
||||||
|
|||||||
@@ -126,36 +126,4 @@ void main() {
|
|||||||
expect(rows.first.result, 'error');
|
expect(rows.first.result, 'error');
|
||||||
expect(rows.first.errorMessage, 'Connection refused');
|
expect(rows.first.errorMessage, 'Connection refused');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
|
||||||
'stores and retrieves stackTrace and isPermanent on error entries',
|
|
||||||
() async {
|
|
||||||
final repo = SyncLogRepositoryImpl(db);
|
|
||||||
final start = DateTime(2024, 3, 1, 9);
|
|
||||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
|
||||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
|
||||||
|
|
||||||
await repo.log(
|
|
||||||
accountId: 'acc1',
|
|
||||||
success: false,
|
|
||||||
errorMessage: 'MissingPluginException',
|
|
||||||
stackTrace: fakeTrace,
|
|
||||||
isPermanent: true,
|
|
||||||
protocol: 'imap',
|
|
||||||
emailsFetched: 0,
|
|
||||||
emailsSkipped: 0,
|
|
||||||
mailboxesSynced: 0,
|
|
||||||
pendingFlushed: 0,
|
|
||||||
bytesTransferred: 0,
|
|
||||||
startedAt: start,
|
|
||||||
finishedAt: end,
|
|
||||||
);
|
|
||||||
|
|
||||||
final entries = await repo.observeSyncLogs('acc1').first;
|
|
||||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
|
||||||
expect(entry.stackTrace, fakeTrace);
|
|
||||||
expect(entry.isPermanent, true);
|
|
||||||
expect(entry.errorMessage, 'MissingPluginException');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,9 +260,8 @@ void main() {
|
|||||||
expect(original!.messageId, isNull); // set a messageId so lookup works
|
expect(original!.messageId, isNull); // set a messageId so lookup works
|
||||||
|
|
||||||
// Seed a messageId so undo can find the email after UID change.
|
// Seed a messageId so undo can find the email after UID change.
|
||||||
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write(
|
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
|
||||||
const EmailsCompanion(messageId: Value('msg-101@test')),
|
.write(const EmailsCompanion(messageId: Value('msg-101@test')));
|
||||||
);
|
|
||||||
|
|
||||||
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
||||||
|
|
||||||
@@ -304,9 +303,8 @@ void main() {
|
|||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 4. Verify the current email row is now in INBOX.
|
// 4. Verify the current email row is now in INBOX.
|
||||||
final inInbox = await (db.select(
|
final inInbox = await (db.select(db.emails)
|
||||||
db.emails,
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||||
)..where((t) => t.mailboxPath.equals('INBOX')))
|
|
||||||
.get();
|
.get();
|
||||||
expect(
|
expect(
|
||||||
inInbox,
|
inInbox,
|
||||||
|
|||||||
@@ -122,74 +122,70 @@ void main() {
|
|||||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('undo pushes inverse action into log when destinationMailboxPath is set',
|
||||||
'undo pushes inverse action into log when destinationMailboxPath is set',
|
() async {
|
||||||
() async {
|
final action = UndoAction(
|
||||||
final action = UndoAction(
|
id: 'del1',
|
||||||
id: 'del1',
|
accountId: 'acc1',
|
||||||
accountId: 'acc1',
|
type: UndoType.delete,
|
||||||
type: UndoType.delete,
|
emailIds: ['e1'],
|
||||||
emailIds: ['e1'],
|
sourceMailboxPath: 'INBOX',
|
||||||
sourceMailboxPath: 'INBOX',
|
destinationMailboxPath: 'Trash',
|
||||||
destinationMailboxPath: 'Trash',
|
);
|
||||||
);
|
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(
|
when(
|
||||||
mockEmailRepo.cancelPendingChange(any, any),
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
).thenAnswer((_) async => false);
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
await notifier.pushAction(action);
|
await notifier.pushAction(action);
|
||||||
await notifier.undo(actionId: 'del1');
|
await notifier.undo(actionId: 'del1');
|
||||||
|
|
||||||
// Original entry stays; inverse is added.
|
// Original entry stays; inverse is added.
|
||||||
final log = container.read(undoServiceProvider);
|
final log = container.read(undoServiceProvider);
|
||||||
expect(log.length, 2);
|
expect(log.length, 2);
|
||||||
expect(log[0].id, 'del1');
|
expect(log[0].id, 'del1');
|
||||||
final inv = log[1];
|
final inv = log[1];
|
||||||
expect(inv.id, 'del1-inv');
|
expect(inv.id, 'del1-inv');
|
||||||
expect(inv.type, UndoType.move);
|
expect(inv.type, UndoType.move);
|
||||||
expect(inv.emailIds, ['e1']);
|
expect(inv.emailIds, ['e1']);
|
||||||
expect(inv.sourceMailboxPath, 'Trash');
|
expect(inv.sourceMailboxPath, 'Trash');
|
||||||
expect(inv.destinationMailboxPath, 'INBOX');
|
expect(inv.destinationMailboxPath, 'INBOX');
|
||||||
verify(
|
verify(
|
||||||
mockUndoRepo.saveAction(
|
mockUndoRepo.saveAction(
|
||||||
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
||||||
),
|
),
|
||||||
).called(1);
|
).called(1);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('undo without destinationMailboxPath does not push inverse action',
|
||||||
'undo without destinationMailboxPath does not push inverse action',
|
() async {
|
||||||
() async {
|
final action = UndoAction(
|
||||||
final action = UndoAction(
|
id: 'mv1',
|
||||||
id: 'mv1',
|
accountId: 'acc1',
|
||||||
accountId: 'acc1',
|
type: UndoType.move,
|
||||||
type: UndoType.move,
|
emailIds: ['e1'],
|
||||||
emailIds: ['e1'],
|
sourceMailboxPath: 'INBOX',
|
||||||
sourceMailboxPath: 'INBOX',
|
// no destinationMailboxPath
|
||||||
// no destinationMailboxPath
|
);
|
||||||
);
|
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(
|
when(
|
||||||
mockEmailRepo.cancelPendingChange(any, any),
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
).thenAnswer((_) async => false);
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
await notifier.pushAction(action);
|
await notifier.pushAction(action);
|
||||||
await notifier.undo(actionId: 'mv1');
|
await notifier.undo(actionId: 'mv1');
|
||||||
|
|
||||||
// Original entry stays; no inverse since no destinationMailboxPath.
|
// Original entry stays; no inverse since no destinationMailboxPath.
|
||||||
final log = container.read(undoServiceProvider);
|
final log = container.read(undoServiceProvider);
|
||||||
expect(log.length, 1);
|
expect(log.length, 1);
|
||||||
expect(log.first.id, 'mv1');
|
expect(log.first.id, 'mv1');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('undo with actionId removes and undos specific action', () async {
|
test('undo with actionId removes and undos specific action', () async {
|
||||||
// action1 has no destination → no inverse action
|
// action1 has no destination → no inverse action
|
||||||
@@ -354,9 +350,13 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Simulate slow DB load
|
// Simulate slow DB load
|
||||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer(
|
when(
|
||||||
(_) =>
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||||
Future.delayed(const Duration(milliseconds: 10), () => [persisted]),
|
).thenAnswer(
|
||||||
|
(_) => Future.delayed(
|
||||||
|
const Duration(milliseconds: 10),
|
||||||
|
() => [persisted],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
|
|||||||
@@ -27,28 +27,11 @@ class MockUrlLauncher extends Mock
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThrowingUrlLauncher extends Mock
|
|
||||||
with MockPlatformInterfaceMixin
|
|
||||||
implements UrlLauncherPlatform {
|
|
||||||
@override
|
|
||||||
Future<bool> canLaunch(String? url) async => true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
|
||||||
throw PlatformException(
|
|
||||||
code: 'channel-error',
|
|
||||||
message: 'Unable to establish connection on channel: '
|
|
||||||
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider
|
||||||
FakeAccountRepository(accounts),
|
.overrideWithValue(FakeAccountRepository(accounts)),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: AboutScreen()),
|
child: const MaterialApp(home: AboutScreen()),
|
||||||
);
|
);
|
||||||
@@ -81,9 +64,6 @@ void main() {
|
|||||||
expect(find.textContaining('Dark Mode'), findsWidgets);
|
expect(find.textContaining('Dark Mode'), findsWidgets);
|
||||||
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
||||||
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
||||||
expect(find.textContaining('Locale'), findsWidgets);
|
|
||||||
expect(find.textContaining('Text Scale'), findsWidgets);
|
|
||||||
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
|
||||||
// Buttons are in the body, not in the AppBar actions
|
// Buttons are in the body, not in the AppBar actions
|
||||||
expect(find.byIcon(Icons.copy), findsOneWidget);
|
expect(find.byIcon(Icons.copy), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
||||||
@@ -152,10 +132,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
addTearDown(
|
addTearDown(
|
||||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
() => tester.binding.defaultBinaryMessenger
|
||||||
SystemChannels.platform,
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
null,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(_buildScreen());
|
await tester.pumpWidget(_buildScreen());
|
||||||
@@ -173,10 +151,10 @@ void main() {
|
|||||||
expect(clipboardText, contains('Dark Mode'));
|
expect(clipboardText, contains('Dark Mode'));
|
||||||
expect(clipboardText, contains('IMAP Accounts'));
|
expect(clipboardText, contains('IMAP Accounts'));
|
||||||
expect(clipboardText, contains('JMAP Accounts'));
|
expect(clipboardText, contains('JMAP Accounts'));
|
||||||
expect(clipboardText, contains('Locale'));
|
expect(
|
||||||
expect(clipboardText, contains('Text Scale'));
|
clipboardText,
|
||||||
expect(clipboardText, contains('DB Schema Version'));
|
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||||
expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)'));
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||||
@@ -202,24 +180,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'AboutScreen link tap with failed url_launcher shows error snackbar',
|
|
||||||
(tester) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
|
|
||||||
|
|
||||||
await tester.pumpWidget(_buildScreen());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.textContaining('sharedinbox.de').first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('Error:'), findsOneWidget);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows expiry countdown hint', (tester) async {
|
testWidgets('shows 20-minute expiry hint', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/receive',
|
initialLocation: '/accounts/receive',
|
||||||
@@ -32,100 +32,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('expires in'), findsOneWidget);
|
expect(find.textContaining('20 minutes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'step 2 button shows text-input fallback on platforms without camera',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/receive',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// On Linux (desktop, no camera) the text fallback field must appear.
|
|
||||||
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'step 2 — valid encrypted QR imports account via text fallback',
|
|
||||||
(tester) async {
|
|
||||||
// Pre-generate a key pair so we can encrypt a QR code with the same
|
|
||||||
// material the screen will use for decryption.
|
|
||||||
final material = await ShareEncryptionService.generateKeyPair();
|
|
||||||
final repo = FakeShareKeyRepository(material: material);
|
|
||||||
|
|
||||||
const account = Account(
|
|
||||||
id: 'src-1',
|
|
||||||
displayName: 'Alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
imapHost: 'imap.example.com',
|
|
||||||
smtpHost: 'smtp.example.com',
|
|
||||||
);
|
|
||||||
|
|
||||||
final encryptedQr = await ShareEncryptionService.encryptAccounts(
|
|
||||||
recipientKeyId: material.keyId,
|
|
||||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
|
||||||
accounts: [
|
|
||||||
AccountPayload(accountJson: account.toJson(), password: 'secret'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/receive',
|
|
||||||
overrides: baseOverrides(shareKeyRepository: repo),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle(); // key generation completes
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(
|
|
||||||
find.byKey(const Key('encryptedCodeField')),
|
|
||||||
encryptedQr,
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Import'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Imported 1 account successfully.'), findsOneWidget);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/receive',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(
|
|
||||||
find.byKey(const Key('encryptedCodeField')),
|
|
||||||
'not-a-valid-qr-code',
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Import'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Screen returns to the pub-key step with an error message visible.
|
|
||||||
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
|
|
||||||
expect(find.textContaining('Import failed:'), findsWidgets);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('AccountSendScreen', () {
|
group('AccountSendScreen', () {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
|
||||||
|
|
||||||
import 'helpers.dart';
|
import 'helpers.dart';
|
||||||
|
|
||||||
@@ -207,73 +206,5 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(find.text('sharedinbox.de'), findsOneWidget);
|
expect(find.text('sharedinbox.de'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows Healthy when sync health is healthy', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
syncHealth: SyncHealthRow(
|
|
||||||
accountId: kTestAccount.id,
|
|
||||||
lastVerifiedAt: DateTime(2024, 6),
|
|
||||||
isHealthy: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows discrepancy details when sync health has discrepancies',
|
|
||||||
(
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
const summary =
|
|
||||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
syncHealth: SyncHealthRow(
|
|
||||||
accountId: kTestAccount.id,
|
|
||||||
lastVerifiedAt: DateTime(2024, 6),
|
|
||||||
isHealthy: false,
|
|
||||||
discrepancySummary: summary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
|
||||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('sync health row is positioned below the account name row', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
syncHealth: SyncHealthRow(
|
|
||||||
accountId: kTestAccount.id,
|
|
||||||
lastVerifiedAt: DateTime(2024, 6),
|
|
||||||
isHealthy: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
|
||||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
|
||||||
expect(healthPos, greaterThan(namePos));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
|
||||||
|
|
||||||
class _FakeAssetBundle extends CachingAssetBundle {
|
|
||||||
final Map<String, String> _assets;
|
|
||||||
_FakeAssetBundle(this._assets);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ByteData> load(String key) async {
|
|
||||||
if (_assets.containsKey(key)) {
|
|
||||||
final encoded = utf8.encode(_assets[key]!);
|
|
||||||
return ByteData.view(Uint8List.fromList(encoded).buffer);
|
|
||||||
}
|
|
||||||
throw FlutterError('Asset not found: "$key"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _fakeChangelog =
|
|
||||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('ChangeLog'), findsOneWidget);
|
|
||||||
expect(find.textContaining('initial release'), findsOneWidget);
|
|
||||||
expect(find.textContaining('resolve crash'), findsOneWidget);
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -96,10 +96,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
addTearDown(
|
addTearDown(
|
||||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
() => tester.binding.defaultBinaryMessenger
|
||||||
SystemChannels.platform,
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
null,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const exception = 'TestException: clipboard test';
|
const exception = 'TestException: clipboard test';
|
||||||
@@ -118,87 +116,57 @@ void main() {
|
|||||||
|
|
||||||
expect(clipboardText, isNotNull);
|
expect(clipboardText, isNotNull);
|
||||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||||
expect(clipboardText, contains('Build Mode:'));
|
|
||||||
expect(clipboardText, contains('Platform:'));
|
expect(clipboardText, contains('Platform:'));
|
||||||
expect(clipboardText, contains('Dart:'));
|
|
||||||
expect(clipboardText, contains('Timestamp:'));
|
|
||||||
expect(clipboardText, contains('TestException: clipboard test'));
|
expect(clipboardText, contains('TestException: clipboard test'));
|
||||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('CrashScreen shows git hash as clickable link above stacktrace', (
|
testWidgets(
|
||||||
tester,
|
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||||
) async {
|
(tester) async {
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() => tester.view.resetPhysicalSize());
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
final mock = MockUrlLauncher();
|
final mock = MockUrlLauncher();
|
||||||
UrlLauncherPlatform.instance = mock;
|
UrlLauncherPlatform.instance = mock;
|
||||||
|
|
||||||
const exception = 'TestException: git hash test';
|
const exception = 'TestException: git hash test';
|
||||||
final stackTrace = StackTrace.current;
|
final stackTrace = StackTrace.current;
|
||||||
const testHash = 'abc1234';
|
const testHash = 'abc1234';
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
CrashScreen(
|
CrashScreen(
|
||||||
exception: exception,
|
exception: exception,
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
gitHash: testHash,
|
gitHash: testHash,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Git hash link should be present
|
// Git hash link should be present
|
||||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||||
expect(gitLinkFinder, findsOneWidget);
|
expect(gitLinkFinder, findsOneWidget);
|
||||||
|
|
||||||
// Link must appear above the stack trace
|
// Link must appear above the stack trace
|
||||||
final stackTraceFinder = find.text('Stack Trace:');
|
final stackTraceFinder = find.text('Stack Trace:');
|
||||||
expect(
|
expect(
|
||||||
tester.getTopLeft(gitLinkFinder).dy,
|
tester.getTopLeft(gitLinkFinder).dy,
|
||||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tapping the link should open the Codeberg commit URL
|
// Tapping the link should open the Codeberg commit URL
|
||||||
await tester.tap(gitLinkFinder);
|
await tester.tap(gitLinkFinder);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
mock.launchedUrl,
|
mock.launchedUrl,
|
||||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(() => tester.view.resetPhysicalSize());
|
|
||||||
|
|
||||||
const exception = 'TestException: info row test';
|
|
||||||
final stackTrace = StackTrace.current;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Info row shows app version (from mock), build mode, and platform OS.
|
|
||||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
|
||||||
// In test builds kDebugMode is true.
|
|
||||||
expect(find.textContaining('debug'), findsOneWidget);
|
|
||||||
// Platform OS is always present (linux in CI, android/ios on device).
|
|
||||||
expect(
|
|
||||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
|
||||||
findsWidgets,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'CrashScreen shows app version as clickable link when git hash is set',
|
'CrashScreen shows app version as clickable link when git hash is set',
|
||||||
@@ -264,10 +232,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
addTearDown(
|
addTearDown(
|
||||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
() => tester.binding.defaultBinaryMessenger
|
||||||
SystemChannels.platform,
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
null,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const exception = 'TestException: version link clipboard test';
|
const exception = 'TestException: version link clipboard test';
|
||||||
|
|||||||
@@ -106,64 +106,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'try connection button is disabled when no password stored or entered',
|
'try connection shows password required when no password stored', (
|
||||||
(tester) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
hasStoredPassword: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
|
||||||
find.byKey(const Key('editTryConnectionButton')),
|
|
||||||
);
|
|
||||||
expect(button.onPressed, isNull);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'try connection button is enabled after typing password with no stored password',
|
|
||||||
(tester) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
hasStoredPassword: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(
|
|
||||||
find.byKey(const Key('editPasswordField')),
|
|
||||||
'mypassword',
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
|
||||||
find.byKey(const Key('editTryConnectionButton')),
|
|
||||||
);
|
|
||||||
expect(button.onPressed, isNotNull);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets('save button is disabled when no password stored or entered', (
|
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
@@ -182,10 +125,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final button = tester.widget<FilledButton>(
|
await tester.tap(find.byKey(const Key('editTryConnectionButton')));
|
||||||
find.widgetWithText(FilledButton, 'Save'),
|
await tester.pumpAndSettle();
|
||||||
);
|
|
||||||
expect(button.onPressed, isNull);
|
// App must not crash; password field shows a validation error.
|
||||||
|
expect(find.text('Required'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('connection error shows error message', (tester) async {
|
testWidgets('connection error shows error message', (tester) async {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user